Skip to main content

ogeom_heal/
fix.rs

1//! Fixing faces: recomputing the trims a face is missing.
2//!
3//! *Elsewhere:* the `ShapeFix_Face` / `ShapeFix_Edge` corner of the fixing
4//! family.
5
6use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
7use ogeom_geom::Transformable as _;
8use ogeom_topo::{EdgeRepr, Model, NodeData, Shape, ShapeType};
9
10/// What [`fix_face_pcurves`] did, edge by edge.
11#[derive(Debug, Default)]
12pub struct FixedTrims {
13    /// Edges that gained a fitted pcurve.
14    pub fitted: usize,
15    /// Edges that already carried one and were left alone.
16    pub already: usize,
17    /// The worst measured edge-to-surface offset among the fitted, now
18    /// recorded in those edges' widened tolerances.
19    pub worst: f64,
20    /// Edges refused (farther from the surface than the cap), with the
21    /// offset each was measured at.
22    pub refused: Vec<(Shape, f64)>,
23}
24
25/// Give a face's pcurve-less edges the trims projection can honestly fit.
26///
27/// The reader heals boundary slop up to a millimetre and hands what it
28/// refuses over in `untrimmed_faces`, face shape included; this is the
29/// instructed follow-up: the
30/// same projection fit, at the cap the caller chooses. Each fitted edge's
31/// tolerance widens to the offset actually measured, so the model says
32/// what it knows; an edge past the cap is reported, not touched.
33///
34/// # Errors
35///
36/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
37/// `face` is not a face, holds no surface, or an edge carries no space
38/// curve to project.
39pub fn fix_face_pcurves(
40    model: &mut Model,
41    face: &Shape,
42    cap: f64,
43    tol: Tolerances,
44) -> OgeomResult<FixedTrims> {
45    if model.kind_of(face)? != ShapeType::Face {
46        ogeom_bail!(Construction, "fix_face_pcurves fixes a face");
47    }
48    let (surface_id, surface) = {
49        let Some(node) = model.node(face) else {
50            ogeom_bail!(Dangling, "face is not in this model");
51        };
52        let NodeData::Face(data) = node.data() else {
53            ogeom_bail!(Construction, "face node holds no face data");
54        };
55        let Some(stored) = model.geometry().surface(data.surface) else {
56            ogeom_bail!(Dangling, "face refers to a surface not in this model");
57        };
58        let placement = face.transform(model.datums())?;
59        (data.surface, stored.transformed(&placement, tol)?)
60    };
61
62    let mut report = FixedTrims::default();
63    for wire in model.ordered_children_of(face)? {
64        for edge in model.ordered_children_of(&wire)? {
65            let (curve, range) = {
66                let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
67                    continue;
68                };
69                if data.pcurve_for(surface_id, edge.location()).is_some() {
70                    report.already += 1;
71                    continue;
72                }
73                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
74                    ogeom_bail!(
75                        Construction,
76                        "an edge has no space curve; nothing can be projected"
77                    );
78                };
79                let Some(geometry) = model.geometry().curve(*curve) else {
80                    ogeom_bail!(Dangling, "an edge names a curve not in this model");
81                };
82                let placed = edge.transform(model.datums())?;
83                (geometry.clone().transformed(&placed, tol)?, *range)
84            };
85            match ogeom_algo::pcurve_fit::fit_projected_pcurve_capped(
86                &curve, range, &surface, cap, tol,
87            ) {
88                Ok((pcurve, _, _, worst_off, _)) => {
89                    report.fitted += 1;
90                    report.worst = report.worst.max(worst_off);
91                    if worst_off > tol.confusion()
92                        && let Some(node) = model.node_mut(&edge)
93                        && let NodeData::Edge(data) = node.data_mut()
94                    {
95                        data.tolerance = data.tolerance.widen_to(worst_off + tol.confusion());
96                    }
97                    ogeom_algo::attach_pcurve(
98                        model,
99                        &edge,
100                        pcurve,
101                        surface_id,
102                        ogeom_topo::Location::identity(),
103                        range,
104                    )?;
105                }
106                Err(refusal) => {
107                    // The measured offset travels in the message; the report
108                    // carries the number a consumer acts on.
109                    let off = refusal
110                        .to_string()
111                        .split_whitespace()
112                        .find_map(|w| w.parse::<f64>().ok())
113                        .unwrap_or(f64::INFINITY);
114                    report.refused.push((edge.clone(), off));
115                }
116            }
117        }
118    }
119    Ok(report)
120}
121
122/// What [`reanchor_boundaries`] did.
123#[derive(Debug, Default)]
124pub struct ReanchoredBoundaries {
125    /// Edges whose space curves moved onto their face's surface.
126    pub moved: usize,
127    /// The worst edge-to-surface offset found before moving.
128    pub worst_before: f64,
129    /// The worst residual after: the fit's honest distance from the
130    /// projected samples.
131    pub worst_after: f64,
132    /// Edges refused (farther out than the cap), with their offsets.
133    pub refused: Vec<(Shape, f64)>,
134}
135
136/// Move boundary curves onto the surfaces they are supposed to bound.
137///
138/// The stronger fix behind [`fix_face_pcurves`]: where that fits a *chart*
139/// through whatever offset the boundary carries, this moves the boundary
140/// itself: each off-surface edge's curve is projected, refitted at its own
141/// parameters (so every chart already speaking the old curve keeps its
142/// same-parameter law), and replaced throughout the shape. The displacement
143/// is not hidden: the edge's and its vertices' tolerances widen to cover
144/// where the boundary *was*, because the neighbouring faces still stand on
145/// the unmoved geometry and honesty about the gap is what keeps them sewn.
146///
147/// An edge shared by several faces moves once, onto the first face that
148/// claims it in face order; the recorded tolerance covers the rest.
149///
150/// # Errors
151///
152/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
153/// shape's structure resists rebuilding; refusals past the cap are reported,
154/// not thrown.
155pub fn reanchor_boundaries(
156    model: &mut Model,
157    shape: &Shape,
158    cap: f64,
159    tol: Tolerances,
160) -> OgeomResult<(ogeom_algo::Built, ReanchoredBoundaries)> {
161    use ogeom_topo::{Filter, explore};
162    let mut report = ReanchoredBoundaries::default();
163    let mut reshape = crate::reshape::Reshape::new();
164    let mut done: std::collections::HashSet<ogeom_topo::TShapeId> =
165        std::collections::HashSet::new();
166
167    const SAMPLES: usize = 33;
168    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
169        let surface = {
170            let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
171                continue;
172            };
173            let Some(stored) = model.geometry().surface(data.surface) else {
174                continue;
175            };
176            let placement = face.transform(model.datums())?;
177            stored.clone().transformed(&placement, tol)?
178        };
179        for edge in explore(model, &face, Filter::OfType(ShapeType::Edge))? {
180            if !done.insert(edge.node()) {
181                continue;
182            }
183            let (curve, range, reprs) = {
184                let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
185                    continue;
186                };
187                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
188                    continue;
189                };
190                let Some(geometry) = model.geometry().curve(*curve) else {
191                    continue;
192                };
193                let placed = edge.transform(model.datums())?;
194                (
195                    geometry.clone().transformed(&placed, tol)?,
196                    *range,
197                    data.representations.clone(),
198                )
199            };
200            // Measure, then move only what is honestly off and under the cap.
201            use ogeom_geom::Curve3d as _;
202            use ogeom_geom::Surface as _;
203            let mut params = Vec::with_capacity(SAMPLES);
204            let mut projected = Vec::with_capacity(SAMPLES);
205            let mut worst = 0.0_f64;
206            let mut seed: Option<(f64, f64)> = None;
207            for i in 0..SAMPLES {
208                #[allow(clippy::cast_precision_loss, reason = "a sample index")]
209                let t = range.0 + (range.1 - range.0) * i as f64 / (SAMPLES - 1) as f64;
210                let p = curve.point_at(t, tol)?;
211                let hit = match seed {
212                    Some(uv) => ogeom_algo::project_on_surface_from(&surface, p, uv, tol)
213                        .or_else(|_| ogeom_algo::project_on_surface(&surface, p, 24, tol))?,
214                    None => ogeom_algo::project_on_surface(&surface, p, 24, tol)?,
215                };
216                seed = Some(hit.parameters);
217                worst = worst.max(hit.distance);
218                params.push(t);
219                projected.push(surface.point_at(hit.parameters.0, hit.parameters.1, tol)?);
220            }
221            if worst <= tol.confusion() * 1e3 {
222                continue; // Already on the surface, to the reader's own bar.
223            }
224            report.worst_before = report.worst_before.max(worst);
225            if worst > cap {
226                report.refused.push((edge.clone(), worst));
227                continue;
228            }
229
230            let fitted = ogeom_geom::fit::fit_points_at(
231                &params,
232                &projected,
233                3,
234                (tol.confusion() * 1e3).max(worst * 1e-3),
235                tol,
236            )?;
237            report.worst_after = report.worst_after.max(fitted.error);
238
239            // The move is recorded before it is made: ends and edge widen to
240            // cover where the boundary was, so every neighbour still meets
241            // it within stated tolerance.
242            // Stored order, not traversal order: the curve's range runs the
243            // stored way, and the rebuilt edge's ends must match it however
244            // this occurrence happens to be oriented.
245            let bounds = model.children_of(&edge)?;
246            let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
247                continue;
248            };
249            for v in [&va, &vb] {
250                if let Some(node) = model.node_mut(v)
251                    && let NodeData::Vertex(data) = node.data_mut()
252                {
253                    data.tolerance = data.tolerance.widen_to(worst + tol.confusion());
254                }
255            }
256            let rebuilt = ogeom_algo::make_edge_between(
257                model,
258                ogeom_geom::Curve::BSpline(fitted.curve),
259                (range.0, range.1),
260                &va,
261                &vb,
262                tol,
263            )?
264            .shape;
265            if let Some(node) = model.node_mut(&rebuilt)
266                && let NodeData::Edge(data) = node.data_mut()
267            {
268                data.tolerance = data.tolerance.widen_to(worst + tol.confusion());
269                // The charts riding the old curve stay: each pcurve speaks
270                // its own surface, whose geometry did not move, and the fit
271                // at the old parameters keeps the same-parameter law.
272                for repr in &reprs {
273                    if !matches!(repr, EdgeRepr::Curve3d { .. }) {
274                        data.add(repr.clone());
275                    }
276                }
277            }
278            reshape.replace(&edge, rebuilt);
279            report.moved += 1;
280        }
281    }
282    if reshape.is_empty() {
283        return Ok((ogeom_algo::Built::from_nothing(shape.clone()), report));
284    }
285    let built = reshape.apply(model, shape)?;
286    Ok((built, report))
287}