Skip to main content

ogeom_heal/
fix.rs

1//! Fixing faces: recomputing the trims a face is missing.
2
3use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
4use ogeom_geom::Transformable as _;
5use ogeom_topo::{EdgeRepr, Model, NodeData, Shape, ShapeType};
6
7/// What [`fix_face_pcurves`] did, edge by edge.
8#[derive(Debug, Default)]
9pub struct FixedTrims {
10    /// Edges that gained a fitted pcurve.
11    pub fitted: usize,
12    /// Edges that already carried one and were left alone.
13    pub already: usize,
14    /// The worst measured edge-to-surface offset among the fitted, now
15    /// recorded in those edges' widened tolerances.
16    pub worst: f64,
17    /// Edges refused (farther from the surface than the cap), with the
18    /// offset each was measured at.
19    pub refused: Vec<(Shape, f64)>,
20    /// Whether the face, a sphere face whose trim came within
21    /// [`ogeom_bool::POLE_MARGIN`] of a pole of its chart, was re-charted
22    /// about an axis whose poles stand clear of it
23    /// ([`ogeom_bool::rechart_clear_of_poles`]).
24    pub recharted: bool,
25}
26
27/// Give a face's pcurve-less edges the trims projection can honestly fit.
28///
29/// The reader heals boundary slop up to a millimetre and hands what it
30/// refuses over in `untrimmed_faces`, face shape included. This is the
31/// follow-up: the same projection fit, at the cap the caller chooses. Each
32/// fitted edge's tolerance widens to the offset actually measured and to
33/// how far the fitted trim, lifted, stands from the curve anywhere along
34/// it, so the model says what it knows. An edge past the cap is reported, not touched.
35/// What the face shares with other shapes is copied first
36/// ([`Model::unshare`]), so their edges keep the trims they have.
37///
38/// A sphere face whose trim, once every edge has its trim, comes within
39/// [`ogeom_bool::POLE_MARGIN`] of a pole of its chart without running
40/// through it is then re-charted about an axis whose poles stand clear of
41/// it: beside a pole no fitted trim follows the curve, and the face would
42/// mesh short and classify wrong.
43///
44/// # Errors
45///
46/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
47/// `face` is not a face, holds no surface, or an edge carries no space
48/// curve to project; [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone)
49/// naming why where such a sphere face cannot be re-charted clear of its
50/// trim.
51pub fn fix_face_pcurves(
52    model: &mut Model,
53    face: &Shape,
54    cap: f64,
55    tol: Tolerances,
56) -> OgeomResult<FixedTrims> {
57    let plan = plan_trims(model, face, tol)?;
58    let fits = fit_trims(std::slice::from_ref(&plan), cap, tol);
59    let mut report = FixedTrims::default();
60    for (plan, fits) in [plan].into_iter().zip(fits) {
61        attach_trims(model, plan, fits, tol, &mut report)?;
62    }
63    if report.refused.is_empty() {
64        match ogeom_bool::rechart_clear_of_poles(model, face, tol)? {
65            ogeom_bool::Recharted::Untouched => {}
66            ogeom_bool::Recharted::Restated => report.recharted = true,
67            ogeom_bool::Recharted::Refused(why) => {
68                ogeom_bail!(NotDone, "{why}");
69            }
70        }
71    }
72    Ok(report)
73}
74
75/// A face's edge uses as the trim fix finds them, in wire order: each
76/// with the curve to project where it holds no pcurve on the face.
77pub(crate) struct TrimPlan {
78    surface_id: ogeom_topo::SurfaceId,
79    /// The face's surface where the face stands, made only when some edge
80    /// needs a fit.
81    surface: Option<ogeom_geom::SurfaceGeometry>,
82    uses: Vec<(Shape, Option<Projected>)>,
83}
84
85/// A curve where its edge stands, over the edge's range, to project.
86type Projected = (ogeom_geom::Curve, (f64, f64));
87
88/// What one edge's fit came to: the pcurve with the worst sample offset
89/// and the offset the edge must state, or a refusal at the offset
90/// measured.
91pub(crate) enum TrimFit {
92    Fitted {
93        pcurve: ogeom_geom::PlanarCurve,
94        worst_off: f64,
95        off: f64,
96    },
97    Refused(f64),
98}
99
100/// Find what [`fix_face_pcurves`] would fit on `face`, without fitting:
101/// the face is unshared, and each edge use without a pcurve on it is
102/// given its curve where the face stands.
103///
104/// # Errors
105///
106/// As [`fix_face_pcurves`].
107pub(crate) fn plan_trims(
108    model: &mut Model,
109    face: &Shape,
110    tol: Tolerances,
111) -> OgeomResult<TrimPlan> {
112    if model.kind_of(face)? != ShapeType::Face {
113        ogeom_bail!(Construction, "fix_face_pcurves fixes a face");
114    }
115    model.unshare(face)?;
116    let surface_id = {
117        let Some(node) = model.node(face) else {
118            ogeom_bail!(Dangling, "face is not in this model");
119        };
120        let NodeData::Face(data) = node.data() else {
121            ogeom_bail!(Construction, "face node holds no face data");
122        };
123        if model.geometry().surface(data.surface).is_none() {
124            ogeom_bail!(Dangling, "face refers to a surface not in this model");
125        }
126        data.surface
127    };
128    let mut uses = Vec::new();
129    for wire in model.ordered_children_of(face)? {
130        for edge in model.ordered_children_of(&wire)? {
131            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
132                continue;
133            };
134            if data.pcurve_for(surface_id, edge.location()).is_some() {
135                uses.push((edge, None));
136                continue;
137            }
138            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
139                ogeom_bail!(
140                    Construction,
141                    "an edge has no space curve; nothing can be projected"
142                );
143            };
144            let Some(geometry) = model.geometry().curve(*curve) else {
145                ogeom_bail!(Dangling, "an edge names a curve not in this model");
146            };
147            let placed = edge.transform(model.datums())?;
148            let job = (geometry.clone().transformed(&placed, tol)?, *range);
149            uses.push((edge, Some(job)));
150        }
151    }
152    let surface = if uses.iter().any(|(_, job)| job.is_some()) {
153        let placement = face.transform(model.datums())?;
154        model
155            .geometry()
156            .surface(surface_id)
157            .map(|stored| stored.transformed(&placement, tol))
158            .transpose()?
159    } else {
160        None
161    };
162    Ok(TrimPlan {
163        surface_id,
164        surface,
165        uses,
166    })
167}
168
169/// Fit every planned edge of `plans` in parallel, each use's fit in its
170/// plan's place: `None` for a use that needs none.
171pub(crate) fn fit_trims(
172    plans: &[TrimPlan],
173    cap: f64,
174    tol: Tolerances,
175) -> Vec<Vec<Option<OgeomResult<TrimFit>>>> {
176    let jobs: Vec<(usize, usize)> = plans
177        .iter()
178        .enumerate()
179        .flat_map(|(p, plan)| {
180            plan.uses
181                .iter()
182                .enumerate()
183                .filter(|(_, (_, job))| job.is_some())
184                .map(move |(u, _)| (p, u))
185        })
186        .collect();
187    let fitted = ogeom_core::parallel::map_ordered(&jobs, |_, &(p, u)| {
188        let plan = &plans[p];
189        let (Some((curve, range)), Some(surface)) = (&plan.uses[u].1, &plan.surface) else {
190            ogeom_bail!(Construction, "a planned trim has nothing to fit");
191        };
192        fit_trim(curve, *range, surface, cap, tol)
193    });
194    let mut out: Vec<Vec<Option<OgeomResult<TrimFit>>>> = plans
195        .iter()
196        .map(|plan| plan.uses.iter().map(|_| None).collect())
197        .collect();
198    for ((p, u), fit) in jobs.into_iter().zip(fitted) {
199        out[p][u] = Some(fit);
200    }
201    out
202}
203
204/// One edge's projected trim on a surface, held to `cap`.
205fn fit_trim(
206    curve: &ogeom_geom::Curve,
207    range: (f64, f64),
208    surface: &ogeom_geom::SurfaceGeometry,
209    cap: f64,
210    tol: Tolerances,
211) -> OgeomResult<TrimFit> {
212    use ogeom_algo::pcurve_fit::CappedFit;
213    match ogeom_algo::pcurve_fit::fit_projected_pcurve_within(curve, range, surface, cap, tol) {
214        Ok(CappedFit::Fitted((pcurve, _, _, worst_off, _))) => {
215            // The edge states where the fitted chart lies, lifted and
216            // measured densely against the curve, as well as the offset
217            // its samples sat at.
218            let gap = ogeom_algo::pcurve_fit::lifted_gap(
219                (curve, range),
220                (&pcurve, range),
221                surface,
222                false,
223                tol,
224            )?;
225            Ok(TrimFit::Fitted {
226                pcurve,
227                worst_off,
228                off: gap.max(worst_off),
229            })
230        }
231        Ok(CappedFit::TooFar(off)) => Ok(TrimFit::Refused(off)),
232        // A projection that cannot converge measured no offset.
233        Err(_) => Ok(TrimFit::Refused(f64::INFINITY)),
234    }
235}
236
237/// Attach a face's fitted trims in wire order, counting into `report`. A
238/// use that has gained its pcurve since it was planned (an edge the wire
239/// walks twice, or that a face on the same surface fixed first) is left
240/// alone.
241///
242/// # Errors
243///
244/// As [`fix_face_pcurves`].
245pub(crate) fn attach_trims(
246    model: &mut Model,
247    plan: TrimPlan,
248    fits: Vec<Option<OgeomResult<TrimFit>>>,
249    tol: Tolerances,
250    report: &mut FixedTrims,
251) -> OgeomResult<()> {
252    for ((edge, job), fit) in plan.uses.into_iter().zip(fits) {
253        let attached = model
254            .node(&edge)
255            .and_then(|n| n.data().as_edge())
256            .is_some_and(|data| data.pcurve_for(plan.surface_id, edge.location()).is_some());
257        let (Some((_, range)), Some(fit), false) = (job, fit, attached) else {
258            report.already += 1;
259            continue;
260        };
261        match fit? {
262            TrimFit::Fitted {
263                pcurve,
264                worst_off,
265                off,
266            } => {
267                report.fitted += 1;
268                report.worst = report.worst.max(worst_off);
269                // Its vertices widen with it: what bounds the edge is never
270                // tighter than the edge.
271                if off > tol.confusion() {
272                    model.widen(&edge, ogeom_core::Tolerance::new(off + tol.confusion())?)?;
273                }
274                ogeom_algo::attach_pcurve(
275                    model,
276                    &edge,
277                    pcurve,
278                    plan.surface_id,
279                    ogeom_topo::Location::identity(),
280                    range,
281                )?;
282            }
283            TrimFit::Refused(off) => report.refused.push((edge, off)),
284        }
285    }
286    Ok(())
287}
288
289/// What [`reanchor_boundaries`] did.
290#[derive(Debug, Default)]
291pub struct ReanchoredBoundaries {
292    /// Edges whose space curves moved onto their face's surface.
293    pub moved: usize,
294    /// The worst edge-to-surface offset found before moving.
295    pub worst_before: f64,
296    /// The worst residual after: the moved curve's distance from the old
297    /// curve's projection, measured between the samples as well as at them.
298    pub worst_after: f64,
299    /// Edges refused (farther out than the cap), with their offsets.
300    pub refused: Vec<(Shape, f64)>,
301}
302
303/// Move boundary curves onto the surfaces they are supposed to bound.
304///
305/// The stronger fix behind [`fix_face_pcurves`]: where that fits a *chart*
306/// through whatever offset the boundary carries, this moves the boundary
307/// itself: each off-surface edge's curve is projected, refitted at its own
308/// parameters (so every chart already speaking the old curve keeps its
309/// same-parameter law), and replaced throughout the shape. The displacement
310/// is not hidden: the edge's and its vertices' tolerances widen to cover
311/// where the boundary *was*, because the neighbouring faces still stand on
312/// the unmoved geometry and honesty about the gap is what keeps them sewn.
313///
314/// An edge shared by several faces moves once, onto the first face that
315/// claims it in face order; the recorded tolerance covers the rest.
316/// What `shape` shares with other shapes is copied first
317/// ([`ogeom_algo::on_own_nodes`]), so their vertices keep their tolerances.
318///
319/// # Errors
320///
321/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
322/// shape's structure resists rebuilding; refusals past the cap are reported,
323/// not thrown.
324pub fn reanchor_boundaries(
325    model: &mut Model,
326    shape: &Shape,
327    cap: f64,
328    tol: Tolerances,
329) -> OgeomResult<(ogeom_algo::Built, ReanchoredBoundaries)> {
330    ogeom_algo::on_own_nodes_with(
331        model,
332        shape,
333        &[],
334        |model, shape, _| reanchor_boundaries_own(model, shape, cap, tol),
335        |(built, _)| &mut built.history,
336    )
337}
338
339/// [`reanchor_boundaries`] on a shape every node below which is its own.
340fn reanchor_boundaries_own(
341    model: &mut Model,
342    shape: &Shape,
343    cap: f64,
344    tol: Tolerances,
345) -> OgeomResult<(ogeom_algo::Built, ReanchoredBoundaries)> {
346    use ogeom_topo::{Filter, explore};
347    let mut report = ReanchoredBoundaries::default();
348    let mut reshape = crate::reshape::Reshape::new();
349    let mut done: ogeom_core::FastSet<ogeom_topo::TShapeId> = ogeom_core::FastSet::default();
350
351    /// The fewest samples an edge is measured at.
352    const SAMPLES: usize = 33;
353    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
354        let surface = {
355            let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
356                continue;
357            };
358            let Some(stored) = model.geometry().surface(data.surface) else {
359                continue;
360            };
361            let placement = face.transform(model.datums())?;
362            stored.clone().transformed(&placement, tol)?
363        };
364        for edge in explore(model, &face, Filter::OfType(ShapeType::Edge))? {
365            if !done.insert(edge.node()) {
366                continue;
367            }
368            let (curve, range, reprs) = {
369                let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
370                    continue;
371                };
372                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
373                    continue;
374                };
375                let Some(geometry) = model.geometry().curve(*curve) else {
376                    continue;
377                };
378                let placed = edge.transform(model.datums())?;
379                (
380                    geometry.clone().transformed(&placed, tol)?,
381                    *range,
382                    data.representations.clone(),
383                )
384            };
385            // Measure, then move only what is honestly off and under the cap.
386            // Sampled at every span of the curve, so a boundary with more
387            // detail than a fixed count of samples is measured whole.
388            use ogeom_geom::Curve3d as _;
389            use ogeom_geom::Surface as _;
390            let stations = ogeom_algo::traced::stations(&curve, range, SAMPLES - 1);
391            // Each projection seeded from where the nearest one before it
392            // landed: consecutive points of a curve are neighbours on the
393            // surface.
394            let mut landed: Vec<(f64, (f64, f64), f64)> = Vec::with_capacity(stations.len());
395            let mut project = |t: f64| -> OgeomResult<(ogeom_math::Point, f64)> {
396                let p = curve.point_at(t, tol)?;
397                let at = landed.partition_point(|l| l.0 < t);
398                let seed = at.checked_sub(1).or((!landed.is_empty()).then_some(0));
399                let hit = match seed.map(|i| landed[i].1) {
400                    Some(uv) => ogeom_algo::project_on_surface_from(&surface, p, uv, tol)
401                        .or_else(|_| ogeom_algo::project_on_surface(&surface, p, 24, tol))?,
402                    None => ogeom_algo::project_on_surface(&surface, p, 24, tol)?,
403                };
404                landed.insert(at, (t, hit.parameters, hit.distance));
405                Ok((
406                    surface.point_at(hit.parameters.0, hit.parameters.1, tol)?,
407                    hit.distance,
408                ))
409            };
410            let mut worst = 0.0_f64;
411            let mut shadow: ogeom_core::FastMap<u64, ogeom_math::Point> =
412                ogeom_core::FastMap::with_capacity_and_hasher(stations.len(), Default::default());
413            for &t in &stations {
414                let (q, off) = project(t)?;
415                worst = worst.max(off);
416                shadow.insert(t.to_bits(), q);
417            }
418            if worst <= tol.confusion() * 1e3 {
419                continue; // Already on the surface, to the reader's own bar.
420            }
421            report.worst_before = report.worst_before.max(worst);
422            if worst > cap {
423                report.refused.push((edge.clone(), worst));
424                continue;
425            }
426
427            // Fitted at the curve's own parameters and held to the target
428            // between the samples too; the error is the worst measured
429            // anywhere, and the displacement the edge states covers it.
430            let target = (tol.confusion() * 1e3).max(worst * 1e-3);
431            let fitted = ogeom_algo::traced::fit_traced(
432                |t| match shadow.get(&t.to_bits()) {
433                    Some(q) => Ok(*q),
434                    None => {
435                        let (q, off) = project(t)?;
436                        worst = worst.max(off);
437                        Ok(q)
438                    }
439                },
440                &stations,
441                3,
442                target,
443                tol,
444            )?;
445            report.worst_before = report.worst_before.max(worst);
446            report.worst_after = report.worst_after.max(fitted.error);
447            let moved_by = worst + fitted.error + tol.confusion();
448
449            // The move is recorded before it is made: ends and edge widen to
450            // cover where the boundary was, so every neighbour still meets
451            // it within stated tolerance.
452            // Stored order, not traversal order: the curve's range runs the
453            // stored way, and the rebuilt edge's ends must match it however
454            // this occurrence happens to be oriented.
455            let bounds = model.children_of(&edge)?;
456            let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
457                continue;
458            };
459            for v in [&va, &vb] {
460                if let Some(node) = model.node_mut(v)
461                    && let NodeData::Vertex(data) = node.data_mut()
462                {
463                    data.tolerance = data.tolerance.widen_to(moved_by);
464                }
465            }
466            let rebuilt = ogeom_algo::make_edge_between(
467                model,
468                ogeom_geom::Curve::BSpline(fitted.curve),
469                (range.0, range.1),
470                &va,
471                &vb,
472                tol,
473            )?
474            .shape;
475            if let Some(node) = model.node_mut(&rebuilt)
476                && let NodeData::Edge(data) = node.data_mut()
477            {
478                data.tolerance = data.tolerance.widen_to(moved_by);
479                // The charts riding the old curve stay: each pcurve speaks
480                // its own surface, whose geometry did not move, and the fit
481                // at the old parameters keeps the same-parameter law.
482                for repr in &reprs {
483                    if !matches!(repr, EdgeRepr::Curve3d { .. }) {
484                        data.add(repr.clone());
485                    }
486                }
487            }
488            // The kept charts are measured against the moved curve, and
489            // the edge states how far they stand from it.
490            ogeom_algo::state_pcurve_gaps_of(model, core::slice::from_ref(&rebuilt), tol)?;
491            reshape.replace(&edge, rebuilt);
492            report.moved += 1;
493        }
494    }
495    if reshape.is_empty() {
496        return Ok((ogeom_algo::Built::from_nothing(shape.clone()), report));
497    }
498    let built = reshape.apply(model, shape)?;
499    Ok((built, report))
500}