Skip to main content

ogeom_heal/
fix_shape.rs

1//! One entry point over a shape nobody promised was well-formed.
2//!
3//! The exchange readers heal what their corpus exhibits, inline and in a
4//! fixed order; a shape built by a caller, or read from a file the corpus
5//! does not resemble, has no such pass. This is it: diagnose, mend what a
6//! mend is known for, diagnose again, and say what changed and what did
7//! not. Nothing is moved that the model's own tolerances do not already
8//! call the same place, and nothing is dropped that the model says is
9//! there: a face with area is a face, whatever its shape.
10//!
11//! What is mended, in order:
12//!
13//! - a wire whose edges are not end to end is put in the order that
14//!   walks them, where one exists;
15//! - an edge shorter than its own vertices' tolerances (the two ends
16//!   the same point by the model's own admission) is collapsed, its two
17//!   vertices made one;
18//! - where one edge of a wire ends at a vertex and the next begins at
19//!   another, no farther apart than a fitted trim may stand off its
20//!   surface, the two vertices are made one, wide enough to reach both:
21//!   the same slop as a curve ending away from its vertex, and stated the
22//!   same way. A gap past that reach is a missing piece of boundary;
23//! - such a gap is filled with a straight edge where that edge is provably
24//!   the one lacking: the gap is shorter than every edge of each face it
25//!   is in and no other vertex of those faces stands as near its ends, the
26//!   line lies on each face's surface within the face's tolerance along the
27//!   chart segment between the edges either side, and each face, filled,
28//!   checks clean. A gap two faces share gets one edge with a pcurve on
29//!   each. Any other gap stays reported;
30//! - a boundary that crosses a pole of its surface (a sphere's, a cone's
31//!   apex) with no edge spanning the pole's row of the chart is given a
32//!   degenerate edge there;
33//! - an edge holding its column on a closed surface as plain pcurves,
34//!   walked once each way on two columns a period apart, is made a seam
35//!   of the surface ([`join_seam_columns`](ogeom_algo::join_seam_columns));
36//! - an edge with no pcurve on a face it bounds is given the trim
37//!   projection can honestly fit, as the readers do;
38//! - a wire that runs out along an edge and straight back along the same
39//!   curve, within the tolerance either edge states and on one copy of
40//!   the chart, with no other face walking either edge, loses the pair (a
41//!   tail); a hole made of such pairs alone goes. Where one of the two runs
42//!   farther than the other (a notch), the longer is cut where the shorter
43//!   turns back, and the stretch out to the tip goes with the shorter edge
44//!   (see `tails`);
45//! - a hole every point of which stands within the face's tolerance of
46//!   the hole's boundary, which no neighbour walks, goes: by the face's own
47//!   tolerance it encloses nothing. That is proved on the chart drawn to the
48//!   scale of a bound on how far the surface carries a step of it, on a
49//!   plane, a cylinder, a cone, a sphere, a torus, a B-spline patch, and a
50//!   revolution or extrusion of a B-spline (see `small_wires`);
51//! - a boundary that crosses itself once in its surface's chart is mended
52//!   where the mend is the one it says (see `crossing`): two consecutive
53//!   edges crossing short of the vertex they meet at, by no more than a
54//!   fitted trim may stand off its surface, are cut back to the crossing
55//!   and the vertex moves there, widened to reach where it stood; any
56//!   other crossing of one wire splits its face into the figure eight's
57//!   two loops. Each face changed must check clean after and cross itself
58//!   nowhere; the report counts the faces still crossing themselves;
59//! - a face with no wires on a surface whose domain is its whole extent (a
60//!   sphere, a torus, a patch) is bounded by that domain's sides, with a
61//!   seam edge where the surface closes and a degenerate edge where it
62//!   pinches to a pole, or else marked as covering it;
63//! - loose faces (a compound of them, or an open shell) are sewn where
64//!   they share edges. Two free edges of their faces that are each
65//!   other's twin across a gap no wider than a fitted trim may stand off
66//!   its surface, the gap shorter than every edge of either face and no
67//!   other vertex as near their ends, first state that gap, each edge and
68//!   vertex as wide as its own, where sewing a copy of the shape shows the
69//!   pair joins (see `free_gaps`);
70//! - faces two of which walk an edge they share the same way are turned
71//!   until each such edge is walked once each way, a shell's as sewing
72//!   turns them, a solid's keeping the orientation most of each run of
73//!   them has;
74//! - a run of a solid's faces facing into its material is turned, where
75//!   most of its faces read that way;
76//! - tolerances are tightened to what the geometry needs;
77//! - an edge whose pcurves stand off its curve by more than it states, and
78//!   a vertex a curve it bounds ends away from by more than either states,
79//!   widen to the gap measured, where that gap is within the reach a
80//!   fitted trim is allowed: slop a reader would have recorded the same
81//!   way. A gap past that reach is no slop and stays reported.
82//!
83//! The free edges of the shape as mended come back chained into contours
84//! ([`free_bounds`](crate::free_bounds())), each of which
85//! [`FreeBounds::wires`](crate::FreeBounds::wires) makes a wire.
86//!
87//! What is not, and where it lives: small faces and small solids stay
88//! (removing a face opens the shell it is in; that is defeaturing, and
89//! the boolean crate has it for the features it knows), and a face across
90//! a grid of patches is not rebuilt as one.
91
92use ogeom_core::FastMap;
93
94use ogeom_algo::{
95    Diagnosis, History, ProbeCache, check_kept, edge_pcurve_gap, edge_vertices, linear_properties,
96    order_edges, sew,
97};
98use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
99use ogeom_mesh::Deflection;
100use ogeom_topo::{Model, Shape, ShapeType, TShapeId, explore_unique};
101
102use crate::{Reshape, reduce_tolerances};
103
104/// What [`fix_shape`] did, and what it found before and after.
105#[derive(Debug, Clone)]
106pub struct FixReport {
107    /// The diagnosis the shape came in with.
108    pub before: Diagnosis,
109    /// The diagnosis it leaves with. Not necessarily valid: what has no
110    /// mend here is still reported.
111    pub after: Diagnosis,
112    /// Wires whose edges were put end to end.
113    pub wires_reordered: usize,
114    /// Edges collapsed to a vertex.
115    pub edges_collapsed: usize,
116    /// Vertices merged into the vertex beside them across a gap in a wire.
117    pub gap_vertices_merged: usize,
118    /// Straight edges made across gaps in wires too wide for their
119    /// vertices to merge.
120    pub lacking_edges_made: usize,
121    /// Degenerate edges made where a boundary crosses a pole.
122    pub pole_edges_made: usize,
123    /// Edges given a pcurve on a face they bound.
124    pub edges_trimmed: usize,
125    /// Edge pairs sewn, and edges still free after, when sewing ran.
126    pub sewn: Option<(usize, usize)>,
127    /// Pairs of loose faces' free edges that are each other's twin across a
128    /// gap, widened to state it before sewing.
129    pub free_gaps_stated: usize,
130    /// Tolerances tightened.
131    pub tolerances_reduced: usize,
132    /// Tolerances widened so that every vertex is at least as loose as the
133    /// edges it bounds and every edge as the faces it bounds.
134    pub tolerances_widened: usize,
135    /// Faces turned the right way out: each faced into its solid's
136    /// material.
137    pub faces_turned: usize,
138    /// Edges widened to how far their pcurves stand off their curves.
139    pub pcurve_gaps_stated: usize,
140    /// Vertices widened to how far the curves they bound end from them.
141    pub curve_ends_stated: usize,
142    /// Pairs of edges stripped from wires that ran out along one and
143    /// straight back along the other.
144    pub tails_stripped: usize,
145    /// Notches cut off: an edge run back along part of the edge before it,
146    /// the shorter edge and the stretch of the longer it ran along gone.
147    pub notches_cut: usize,
148    /// Holes removed that enclosed no area the face's tolerance resolves:
149    /// every point of each within that tolerance of its boundary.
150    pub small_wires_removed: usize,
151    /// Pairs of consecutive edges crossing short of the vertex they meet
152    /// at, cut back to the crossing.
153    pub crossings_trimmed: usize,
154    /// Faces whose boundary crossed itself, split into its two loops.
155    pub faces_split_at_crossings: usize,
156    /// Faces of the shape as mended whose boundary still crosses itself
157    /// in its surface's chart ([`chart_crossings`](ogeom_algo::chart_crossings)).
158    pub crossings_left: usize,
159    /// Faces with no wires bounded by the sides of their surface's domain.
160    pub natural_bounds_made: usize,
161    /// Faces with no wires marked as covering their whole surface, where
162    /// the sides of its domain could not bound them.
163    pub natural_bounds_marked: usize,
164    /// The free edges of the shape as mended, chained into contours.
165    pub free_bounds: crate::FreeBounds,
166}
167
168/// A fixed shape: the result, its history, and the report.
169#[derive(Debug, Clone)]
170pub struct Fixed {
171    /// The shape as mended. The input where nothing was.
172    pub shape: Shape,
173    /// What became of every input node.
174    pub history: History,
175    /// What was done and what remains.
176    pub report: FixReport,
177}
178
179/// The cap on how far a fitted trim may sit from its surface: the
180/// readers' own, a millimetre at unit scale.
181const TRIM_CAP: f64 = 1e7;
182
183/// Mend `shape` where a mend is known for what is wrong with it.
184///
185/// See the module documentation for what is and is not done.
186///
187/// # Errors
188///
189/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle
190/// fails to resolve; [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
191/// if a rebuilt container comes out empty.
192pub fn fix_shape(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Fixed> {
193    // Every mend below edits in place what it reaches, so what other
194    // shapes hold as well is copied first, and the copies recorded.
195    let mut history = ogeom_algo::unshare(model, shape)?;
196    // The orientation probe is asked three times, before, while mending
197    // and after; each asks again only what the steps between changed.
198    let kept = ProbeCache::new();
199    let before = check_kept(model, shape, tol, &kept)?;
200    let mut current = shape.clone();
201
202    // Wires and small edges, in one rebuild.
203    let mut reshape = Reshape::new();
204    let wires_reordered = reorder_wires(model, &current, &mut reshape, tol)?;
205    let edges_collapsed = collapse_small_edges(model, &current, &mut reshape, tol)?;
206    if !reshape.is_empty() {
207        let built = reshape.apply(model, &current)?;
208        history = history.then(&built.history);
209        current = built.shape;
210    }
211
212    // Gaps between consecutive edges, on the wires as reordered: a wire
213    // out of order has no consecutive edges to speak of.
214    let mut gaps = Reshape::new();
215    let gap_vertices_merged = merge_wire_gaps(model, &current, &mut gaps, tol)?;
216    if !gaps.is_empty() {
217        let built = gaps.apply(model, &current)?;
218        history = history.then(&built.history);
219        current = built.shape;
220    }
221
222    // Lacking edges across the gaps merging leaves, where a straight edge
223    // provably fills them.
224    let mut lacking = Reshape::new();
225    let lacking_edges_made = crate::lacking::stage_lacking_edges(
226        model,
227        &current,
228        &mut lacking,
229        tol.confusion() * TRIM_CAP,
230        tol,
231    )?;
232    if !lacking.is_empty() {
233        let built = lacking.apply(model, &current)?;
234        history = history.then(&built.history);
235        current = built.shape;
236    }
237
238    // Degenerate edges where a boundary crosses a pole, on the wires as
239    // reordered, collapsed and closed: a collapsed edge at a pole leaves
240    // the gap.
241    let mut poles = Reshape::new();
242    let pole_edges_made = crate::pole::stage_pole_edges(model, &current, &mut poles, tol)?;
243    if !poles.is_empty() {
244        let built = poles.apply(model, &current)?;
245        history = history.then(&built.history);
246        current = built.shape;
247    }
248
249    // Seams stored as plain pcurves, joined.
250    ogeom_algo::join_seam_columns(model, &current, tol)?;
251
252    // Trims for edges that have none on a face they bound.
253    // Every face planned, the fits made side by side, then attached face
254    // by face in order.
255    let plans = explore_unique(model, &current, ShapeType::Face)?
256        .iter()
257        .map(|face| crate::fix::plan_trims(model, face, tol))
258        .collect::<OgeomResult<Vec<_>>>()?;
259    let fits = crate::fix::fit_trims(&plans, tol.confusion() * TRIM_CAP, tol);
260    let mut trims = crate::FixedTrims::default();
261    for (plan, fits) in plans.into_iter().zip(fits) {
262        crate::fix::attach_trims(model, plan, fits, tol, &mut trims)?;
263    }
264    let edges_trimmed = trims.fitted;
265
266    // Tails, on the wires as trimmed: a tail is told by its pcurves, and
267    // is stripped before sewing could join one of its edges to a
268    // neighbour's.
269    let mut tails = Reshape::new();
270    let tails_stripped = crate::tails::stage_tails(model, &current, &mut tails, tol)?;
271    if !tails.is_empty() {
272        let built = tails.apply(model, &current)?;
273        history = history.then(&built.history);
274        current = built.shape;
275    }
276    // Notches, on the wires stripped of tails: a tail on a notch's tip
277    // has gone, and what runs back is the notch's own edge.
278    let mut notches = Reshape::new();
279    let notches_cut = crate::tails::stage_notches(model, &current, &mut notches, tol)?;
280    if !notches.is_empty() {
281        let built = notches.apply(model, &current)?;
282        history = history.then(&built.history);
283        current = built.shape;
284    }
285
286    // Holes enclosing nothing the face's tolerance resolves, read on the
287    // wires stripped of tails and notches.
288    let mut small = Reshape::new();
289    let small_wires_removed =
290        crate::small_wires::stage_small_wires(model, &current, &mut small, tol)?;
291    if !small.is_empty() {
292        let built = small.apply(model, &current)?;
293        history = history.then(&built.history);
294        current = built.shape;
295    }
296
297    // Boundaries crossing themselves in their charts, read on the wires as
298    // trimmed and stripped of tails.
299    let mut crossings = Reshape::new();
300    let crossed = crate::crossing::stage_crossings(
301        model,
302        &current,
303        &mut crossings,
304        tol.confusion() * TRIM_CAP,
305        tol,
306    )?;
307    if !crossings.is_empty() {
308        let built = crossings.apply(model, &current)?;
309        history = history.then(&built.history);
310        current = built.shape;
311    }
312    let mut natural = Reshape::new();
313    let (natural_bounds_made, natural_bounds_marked) =
314        bound_natural_faces(model, &current, &mut natural, tol)?;
315    if !natural.is_empty() {
316        let built = natural.apply(model, &current)?;
317        history = history.then(&built.history);
318        current = built.shape;
319    }
320
321    // Loose faces sewn: a compound of faces, or an open shell.
322    let mut sewn = None;
323    let kind = model.kind_of(&current)?;
324    let mut free_gaps_stated = 0;
325    if matches!(kind, ShapeType::Compound | ShapeType::Shell) {
326        // Twins across a gap, stated as it so that sewing joins them.
327        free_gaps_stated =
328            crate::free_gaps::state_free_gaps(model, &current, tol.confusion() * TRIM_CAP, tol)?;
329        let faces = explore_unique(model, &current, ShapeType::Face)?;
330        if !faces.is_empty() {
331            let result = sew(model, &faces, tol)?;
332            sewn = Some((result.joined, result.free_edges.len()));
333            // Rebuilt when sewing joined anything, when the faces already
334            // closed among themselves and only the container said
335            // otherwise (a compound of faces that is a shell becomes one),
336            // or when sewing turned faces two of which walked an edge they
337            // share the same way, where no solid holds them.
338            let turned = explore_unique(model, &current, ShapeType::Solid)?.is_empty()
339                && ogeom_algo::agreeing_orientation(model, &faces)?
340                    .is_some_and(|agreement| agreement.turn.contains(&true));
341            if result.joined > 0
342                || (kind == ShapeType::Compound && result.free_edges.is_empty())
343                || turned
344            {
345                let rebuilt = match (kind, result.shells.len()) {
346                    (ShapeType::Shell, 1) => result.shells[0].clone(),
347                    _ => ogeom_algo::make_compound(model, &result.shells)?.shape,
348                };
349                let mut step = result.history;
350                step.modify(&current, rebuilt.clone());
351                history = history.then(&step);
352                current = rebuilt;
353            }
354        }
355    }
356
357    // The faces of each solid turned, first by the topology alone, then
358    // by the material. See `solid_turns`.
359    let mut faces_turned = 0;
360    for by_material in [false, true] {
361        let mut turn = Reshape::new();
362        for solid in explore_unique(model, &current, ShapeType::Solid)? {
363            for face in solid_turns(model, &solid, by_material, tol, &kept)? {
364                turn.replace(&face, turned_face(model, &face)?.reversed());
365                faces_turned += 1;
366            }
367        }
368        if !turn.is_empty() {
369            let built = turn.apply(model, &current)?;
370            history = history.then(&built.history);
371            current = built.shape;
372        }
373    }
374
375    let tolerances_reduced = reduce_tolerances(model, &current, tol)?;
376    // After the reduction, which measures more coarsely than the checker
377    // and may leave an edge tighter than its pcurves' gap.
378    let pcurve_gaps_stated = state_pcurve_gaps(model, &current, tol)?;
379    let curve_ends_stated = state_curve_ends(model, &current, tol)?;
380    // Last, because every step above may leave a vertex tighter than an
381    // edge it bounds (a reduction tightens edges and faces, never below
382    // what they bound, but a shape can arrive broken), and containment is
383    // established only by widening what is bounded.
384    let tolerances_widened = ogeom_algo::restore_containment(model, &current)?;
385    let after = check_kept(model, &current, tol, &kept)?;
386    let mut crossings_left = 0;
387    for face in explore_unique(model, &current, ShapeType::Face)? {
388        if !ogeom_algo::chart_crossings(model, &face, tol)?.is_empty() {
389            crossings_left += 1;
390        }
391    }
392    let free_bounds = crate::free_bounds::free_bounds(model, &current)?;
393    Ok(Fixed {
394        shape: current,
395        history,
396        report: FixReport {
397            before,
398            after,
399            wires_reordered,
400            edges_collapsed,
401            gap_vertices_merged,
402            lacking_edges_made,
403            pole_edges_made,
404            edges_trimmed,
405            sewn,
406            free_gaps_stated,
407            tolerances_reduced,
408            tolerances_widened,
409            faces_turned,
410            pcurve_gaps_stated,
411            curve_ends_stated,
412            tails_stripped,
413            notches_cut,
414            small_wires_removed,
415            crossings_trimmed: crossed.trimmed,
416            faces_split_at_crossings: crossed.split,
417            crossings_left,
418            natural_bounds_made,
419            natural_bounds_marked,
420            free_bounds,
421        },
422    })
423}
424
425/// Bound every face of `shape` with no wires that is not marked as
426/// covering its whole surface, where the surface's parameter domain is its
427/// whole extent (a sphere, a torus, a Bézier or B-spline patch): what the
428/// face is a face of, and what the mesher and every other reader take a
429/// face with no wires to be. The face is rebuilt with the sides of that
430/// domain as its wire, a periodic pair of sides one seam edge and a side
431/// the surface pinches to a point a degenerate edge, as a whole surface is
432/// bounded before it is divided; where those sides have no closed-form
433/// curve, or the face so bounded does not check clean, it is marked
434/// instead. On any other surface the domain is a window standing in for an
435/// unbounded sheet (a plane's says where its face was built), and the face
436/// stays reported. Returns how many faces are bounded and how many marked.
437fn bound_natural_faces(
438    model: &mut Model,
439    shape: &Shape,
440    reshape: &mut Reshape,
441    tol: Tolerances,
442) -> OgeomResult<(usize, usize)> {
443    use ogeom_geom::{Surface as _, SurfaceKind};
444    let (mut bounded, mut marked) = (0, 0);
445    for face in explore_unique(model, shape, ShapeType::Face)? {
446        if !model.children_of(&face)?.is_empty() {
447            continue;
448        }
449        let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
450            continue;
451        };
452        if data.natural_restriction {
453            continue;
454        }
455        let Some(surface) = model.geometry().surface(data.surface) else {
456            continue;
457        };
458        if !matches!(
459            surface.kind(),
460            SurfaceKind::Sphere | SurfaceKind::Torus | SurfaceKind::Bezier | SurfaceKind::BSpline
461        ) {
462            continue;
463        }
464        let stored = face.oriented(ogeom_topo::Orientation::Forward);
465        if let Some(whole) = crate::divide::bounded_natural(model, &stored, tol)?
466            && ogeom_algo::check(model, &whole, tol)?.is_valid()
467        {
468            reshape.replace(&stored, whole);
469            bounded += 1;
470            continue;
471        }
472        if let Some(node) = model.node_mut(&face)
473            && let ogeom_topo::NodeData::Face(data) = node.data_mut()
474        {
475            data.natural_restriction = true;
476            marked += 1;
477        }
478    }
479    Ok((bounded, marked))
480}
481
482/// Widen every edge whose pcurves stand off its curve farther than it
483/// states, by no more than a fitted trim may stand off its surface, to the
484/// gap, measured as densely as the checker samples and more. Returns how
485/// many edges widened.
486fn state_pcurve_gaps(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<usize> {
487    let cap = tol.confusion() * TRIM_CAP;
488    let edges = explore_unique(model, shape, ShapeType::Edge)?;
489    let gaps = {
490        let model = &*model;
491        ogeom_core::parallel::map_ordered(&edges, |_, edge| edge_pcurve_gap(model, edge, tol))
492    };
493    let mut slack = Vec::new();
494    for (edge, gap) in edges.into_iter().zip(gaps) {
495        let Some(gap) = gap? else { continue };
496        let stated = model.tolerance_of(&edge)?.map_or(0.0, |t| t.get());
497        if gap > stated && gap <= cap {
498            slack.push(edge);
499        }
500    }
501    ogeom_algo::state_pcurve_gaps_of(model, &slack, tol)?;
502    Ok(slack.len())
503}
504
505/// Widen every vertex that a curve it bounds ends away from, farther than
506/// both the vertex and the edge state and by no more than a fitted trim
507/// may stand off its surface, to the widest such gap. Measured as the
508/// checker measures it: the curve's point at the end of its range, placed
509/// as the edge is, against the vertex placed as it is. Returns how many
510/// vertices widened.
511fn state_curve_ends(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<usize> {
512    let cap = tol.confusion() * TRIM_CAP;
513    let mut widest: FastMap<TShapeId, usize> = FastMap::default();
514    let mut widened: Vec<(Shape, f64)> = Vec::new();
515    for edge in explore_unique(model, shape, ShapeType::Edge)? {
516        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
517            continue;
518        };
519        let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
520            continue;
521        };
522        let Some(geometry) = model.geometry().curve(*curve) else {
523            continue;
524        };
525        let reach = data.tolerance.get().max(tol.confusion());
526        let placement = edge.transform(model.datums())?;
527        let bounds = model.children_of(&edge)?;
528        for (parameter, vertex) in [(range.0, bounds.first()), (range.1, bounds.last())] {
529            let Some(vertex) = vertex else { continue };
530            let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
531                continue;
532            };
533            let placed = vertex.transform(model.datums())?.apply(data.point);
534            let on_curve =
535                placement.apply(ogeom_geom::Curve3d::point_at(geometry, parameter, tol)?);
536            let gap = on_curve.distance(placed);
537            if gap > reach.max(data.tolerance.get()) && gap <= cap {
538                let at = *widest.entry(vertex.node()).or_insert_with(|| {
539                    widened.push((vertex.clone(), gap));
540                    widened.len() - 1
541                });
542                widened[at].1 = widened[at].1.max(gap);
543            }
544        }
545    }
546    for (vertex, gap) in &widened {
547        model.widen(vertex, ogeom_core::Tolerance::new(*gap)?)?;
548    }
549    Ok(widened.len())
550}
551
552/// The faces of `solid` to turn: by the topology alone when not
553/// `by_material`, else by the material.
554///
555/// By the topology: where two faces walk an edge they share the same way,
556/// the faces of each run joined by such edges turn until every one is
557/// walked once each way, the run keeping the orientation most of its faces
558/// have ([`agreeing_orientation`](ogeom_algo::agreeing_orientation)).
559///
560/// By the material: a run whose faces agree faces one way throughout, so
561/// it turns as a whole, and only where more than half its faces read
562/// facing into the material. A minority reading in is a misreading, as on
563/// a boundary that passes through itself, and turning those faces alone
564/// would set them against every neighbour. Where the topology admits no
565/// agreement (a band with a half twist), each face reading in is turned on
566/// its own.
567fn solid_turns(
568    model: &Model,
569    solid: &Shape,
570    by_material: bool,
571    tol: Tolerances,
572    kept: &ProbeCache,
573) -> OgeomResult<Vec<Shape>> {
574    let faces = explore_unique(model, solid, ShapeType::Face)?;
575    let agreement = ogeom_algo::agreeing_orientation(model, &faces)?;
576    if !by_material {
577        return Ok(agreement.map_or_else(Vec::new, |agreement| {
578            faces
579                .iter()
580                .zip(&agreement.turn)
581                .filter(|(_, turn)| **turn)
582                .map(|(face, _)| face.clone())
583                .collect()
584        }));
585    }
586    let inward = ogeom_algo::inside_out_faces_kept(model, solid, tol, kept)?;
587    let Some(agreement) = agreement else {
588        return Ok(inward);
589    };
590    let reads_in = |k: &usize| inward.iter().any(|face| face.is_same(&faces[*k]));
591    let mut out = Vec::new();
592    for run in &agreement.runs {
593        if 2 * run.iter().filter(|k| reads_in(k)).count() > run.len() {
594            out.extend(run.iter().map(|&k| faces[k].clone()));
595        }
596    }
597    Ok(out)
598}
599
600/// Stage a rebuilt wire for every wire whose edges do not walk end to end
601/// but can be put in an order that does.
602fn reorder_wires(
603    model: &mut Model,
604    shape: &Shape,
605    reshape: &mut Reshape,
606    tol: Tolerances,
607) -> OgeomResult<usize> {
608    let mut count = 0;
609    for wire in explore_unique(model, shape, ShapeType::Wire)? {
610        let edges = model.ordered_children_of(&wire)?;
611        if edges.len() < 2 || walks_end_to_end(model, &edges, tol)? {
612            continue;
613        }
614        // A bag that is no path at all (a branch, a gap) is left as it
615        // is and reported by the diagnosis; reordering cannot mend it.
616        let Ok(ordered) = order_edges(model, &edges, tol) else {
617            continue;
618        };
619        // Built as the bag it is: consecutive ends that meet at two
620        // vertices rather than one are no order problem, and are made one
621        // with the other gaps.
622        let rebuilt = model.add_wire(&ordered)?;
623        reshape.replace(&wire, rebuilt);
624        count += 1;
625    }
626    Ok(count)
627}
628
629/// Whether consecutive edges meet, the last back at the first: at one
630/// vertex, or at two the model's tolerances call one place, which the gap
631/// step makes one.
632fn walks_end_to_end(model: &Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<bool> {
633    for i in 0..edges.len() {
634        let (Some((_, end)), Some((next, _))) = (
635            edge_vertices(model, &edges[i])?,
636            edge_vertices(model, &edges[(i + 1) % edges.len()])?,
637        ) else {
638            return Ok(false);
639        };
640        if !end.is_same(&next)
641            && !model.same_position(&end, &next, tol)?
642            && !one_place(model, &end, &next, tol)?
643        {
644            return Ok(false);
645        }
646    }
647    Ok(true)
648}
649
650/// Whether two vertices stand within the wider of their tolerances.
651fn one_place(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<bool> {
652    let mut reach = tol.confusion();
653    let mut points = Vec::with_capacity(2);
654    for v in [a, b] {
655        let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) else {
656            return Ok(false);
657        };
658        reach = reach.max(data.tolerance.get());
659        points.push(v.transform(model.datums())?.apply(data.point));
660    }
661    Ok(points[0].distance(points[1]) <= reach)
662}
663
664/// Stage the collapse of every edge shorter than its vertices' tolerances:
665/// the edge goes, and its far vertex becomes its near one.
666///
667/// A run of such edges collapses to one vertex, not a chain of
668/// substitutions: the survivor of each merge is found through the merges
669/// before it.
670fn collapse_small_edges(
671    model: &mut Model,
672    shape: &Shape,
673    reshape: &mut Reshape,
674    tol: Tolerances,
675) -> OgeomResult<usize> {
676    let mut survivor: FastMap<TShapeId, Shape> = FastMap::default();
677    fn root(survivor: &FastMap<TShapeId, Shape>, v: &Shape) -> Shape {
678        let mut current = v.clone();
679        while let Some(next) = survivor.get(&current.node()) {
680            if next.node() == current.node() {
681                break;
682            }
683            current = next.clone();
684        }
685        current
686    }
687    let mut count = 0;
688    for edge in explore_unique(model, shape, ShapeType::Edge)? {
689        let Some((a, b)) = edge_vertices(model, &edge)? else {
690            continue;
691        };
692        if a.is_same(&b) {
693            // A closed edge is a loop, however short.
694            continue;
695        }
696        let reach = [&a, &b]
697            .iter()
698            .filter_map(|v| model.tolerance_of(v).ok().flatten())
699            .map(|t| t.get())
700            .fold(tol.confusion(), f64::max);
701        // An edge is at least as long as the gap between its ends: where
702        // that alone passes the reach, nothing needs measuring.
703        let ends = |v: &Shape| -> OgeomResult<Option<ogeom_math::Point>> {
704            let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) else {
705                return Ok(None);
706            };
707            Ok(Some(v.transform(model.datums())?.apply(data.point)))
708        };
709        if let (Some(pa), Some(pb)) = (ends(&a)?, ends(&b)?)
710            && pa.distance(pb) > reach
711        {
712            continue;
713        }
714        let length = linear_properties(model, &edge, Deflection::default(), tol)?.mass;
715        if length > reach {
716            continue;
717        }
718        let (keep, drop) = (root(&survivor, &a), root(&survivor, &b));
719        if keep.is_same(&drop) {
720            // Already one vertex through earlier collapses; the edge is a
721            // loop on it and goes.
722            reshape.remove(&edge);
723            count += 1;
724            continue;
725        }
726        survivor.insert(drop.node(), keep.clone());
727        reshape.remove(&edge);
728        count += 1;
729    }
730    // The survivor stands where it stood, and the curves that ended at each
731    // vertex it absorbs still end there: it widens to reach every one, as
732    // far as the absorbed vertex stood plus that vertex's own tolerance.
733    // Merged without it, the neighbours of a collapsed edge stop the
734    // collapsed length short of their vertex and the wire gapes.
735    let placed = |model: &Model, v: &Shape| -> OgeomResult<Option<(ogeom_math::Point, f64)>> {
736        let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) else {
737            return Ok(None);
738        };
739        let (point, own) = (data.point, data.tolerance.get());
740        Ok(Some((v.transform(model.datums())?.apply(point), own)))
741    };
742    for vertex in explore_unique(model, shape, ShapeType::Vertex)? {
743        let to = root(&survivor, &vertex);
744        if !to.is_same(&vertex) {
745            if let (Some((from, reach)), Some((at, _))) =
746                (placed(model, &vertex)?, placed(model, &to)?)
747            {
748                let need = from.distance(at) + reach;
749                model.widen(&to, ogeom_core::Tolerance::new(need.max(tol.confusion()))?)?;
750            }
751            reshape.replace(&vertex, to);
752        }
753    }
754    if count > 0 && reshape.is_empty() {
755        ogeom_bail!(Construction, "a collapse staged nothing");
756    }
757    Ok(count)
758}
759
760/// Stage the merge of the two vertices at every joint of a wire where one
761/// edge ends at a vertex and the next begins at another no farther away
762/// than a fitted trim may stand off its surface. Returns how many vertices
763/// are merged into another.
764///
765/// Merges chain: vertices joined through joints are one class, kept as the
766/// first met. A class is not merged where merging would change more than
767/// the joints it closes: where two of its vertices bound one edge (the
768/// edge would close on itself), where it would make two joints of one
769/// face meet that share no vertex now (the boundary would pinch), where a
770/// vertex of it is held by an edge under a placement of its own (its point
771/// is not in the edge's frame), or where a vertex of it lies past the reach
772/// from the one kept. What is not merged stays reported.
773///
774/// The vertex kept widens to reach every vertex it absorbs, as far as that
775/// vertex stood plus that vertex's own tolerance, so the curves that ended
776/// within each still end within it.
777fn merge_wire_gaps(
778    model: &mut Model,
779    shape: &Shape,
780    reshape: &mut Reshape,
781    tol: Tolerances,
782) -> OgeomResult<usize> {
783    fn find(parent: &FastMap<TShapeId, TShapeId>, id: TShapeId) -> TShapeId {
784        let mut current = id;
785        while let Some(&up) = parent.get(&current) {
786            if up == current {
787                break;
788            }
789            current = up;
790        }
791        current
792    }
793    let cap = tol.confusion() * TRIM_CAP;
794    // The joints of each face's wires together, and of each wire no face
795    // holds on its own: the vertex the edge before ends at and the one the
796    // edge after begins at.
797    let mut boundaries: Vec<Vec<Shape>> = Vec::new();
798    let mut held = ogeom_core::FastSet::default();
799    for face in explore_unique(model, shape, ShapeType::Face)? {
800        let wires = model.children_of(&face)?;
801        held.extend(wires.iter().map(Shape::node));
802        boundaries.push(wires);
803    }
804    for wire in explore_unique(model, shape, ShapeType::Wire)? {
805        if !held.contains(&wire.node()) {
806            boundaries.push(vec![wire]);
807        }
808    }
809    let point = |model: &Model, v: &Shape| -> OgeomResult<Option<ogeom_math::Point>> {
810        let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) else {
811            return Ok(None);
812        };
813        Ok(Some(v.transform(model.datums())?.apply(data.point)))
814    };
815    let mut joints: Vec<Vec<[TShapeId; 2]>> = Vec::with_capacity(boundaries.len());
816    let mut parent: FastMap<TShapeId, TShapeId> = FastMap::default();
817    let mut order: Vec<TShapeId> = Vec::new();
818    for wires in &boundaries {
819        let mut at = Vec::new();
820        for wire in wires {
821            let edges = model.ordered_children_of(wire)?;
822            for i in 0..edges.len() {
823                let (Some((_, end)), Some((next, _))) = (
824                    edge_vertices(model, &edges[i])?,
825                    edge_vertices(model, &edges[(i + 1) % edges.len()])?,
826                ) else {
827                    continue;
828                };
829                at.push([end.node(), next.node()]);
830                if end.node() == next.node()
831                    || !end
832                        .location()
833                        .is_same_placement(next.location(), model.datums(), tol)?
834                {
835                    continue;
836                }
837                let (Some(p), Some(q)) = (point(model, &end)?, point(model, &next)?) else {
838                    continue;
839                };
840                if p.distance(q) > cap {
841                    continue;
842                }
843                for id in [end.node(), next.node()] {
844                    if !parent.contains_key(&id) {
845                        parent.insert(id, id);
846                        order.push(id);
847                    }
848                }
849                let (keep, absorb) = (find(&parent, end.node()), find(&parent, next.node()));
850                if keep != absorb {
851                    parent.insert(absorb, keep);
852                }
853            }
854        }
855        joints.push(at);
856    }
857    if order.is_empty() {
858        return Ok(0);
859    }
860    let mut refused = ogeom_core::FastSet::default();
861    for edge in explore_unique(model, shape, ShapeType::Edge)? {
862        let bounds = model.children_of(&Shape::of(edge.node()))?;
863        for vertex in &bounds {
864            if !vertex.location().is_identity() && parent.contains_key(&vertex.node()) {
865                refused.insert(find(&parent, vertex.node()));
866            }
867        }
868        if let [first, .., last] = &bounds[..]
869            && first.node() != last.node()
870            && parent.contains_key(&first.node())
871            && find(&parent, first.node()) == find(&parent, last.node())
872        {
873            refused.insert(find(&parent, first.node()));
874        }
875    }
876    let class = |id: TShapeId| parent.contains_key(&id).then(|| find(&parent, id));
877    for at in &joints {
878        let touched: Vec<&[TShapeId; 2]> = at
879            .iter()
880            .filter(|joint| joint.iter().any(|id| class(*id).is_some()))
881            .collect();
882        for (j, a) in touched.iter().enumerate() {
883            for b in &touched[j + 1..] {
884                if a.iter().any(|id| b.contains(id)) {
885                    continue;
886                }
887                for x in a.iter().filter_map(|id| class(*id)) {
888                    if b.iter().any(|id| class(*id) == Some(x)) {
889                        refused.insert(x);
890                    }
891                }
892            }
893        }
894    }
895    let mut need: FastMap<TShapeId, f64> = FastMap::default();
896    let mut absorbed = Vec::new();
897    for &id in &order {
898        let keep = find(&parent, id);
899        if keep == id {
900            continue;
901        }
902        let (Some(from), Some(to)) = (
903            point(model, &Shape::of(id))?,
904            point(model, &Shape::of(keep))?,
905        ) else {
906            refused.insert(keep);
907            continue;
908        };
909        let reach = model.tolerance_of(&Shape::of(id))?.map_or(0.0, |t| t.get());
910        if from.distance(to) > cap {
911            refused.insert(keep);
912        }
913        let wide = need.entry(keep).or_insert(tol.confusion());
914        *wide = wide.max(from.distance(to) + reach);
915        absorbed.push((id, keep));
916    }
917    let mut count = 0;
918    for (id, keep) in absorbed {
919        if refused.contains(&keep) {
920            continue;
921        }
922        reshape.replace(&Shape::of(id), Shape::of(keep));
923        count += 1;
924    }
925    for (keep, wide) in need {
926        if !refused.contains(&keep) {
927            model.widen(&Shape::of(keep), ogeom_core::Tolerance::new(wide)?)?;
928        }
929    }
930    Ok(count)
931}
932
933/// A new face node holding the same surface and wires as `face`'s, for
934/// replacing it turned: a replacement by the node itself is no change. It
935/// stands where `face` does: a face placed by a location (an assembly
936/// part's, read from a file) keeps it, or the void it bounds would move out
937/// of its solid.
938fn turned_face(model: &mut Model, face: &Shape) -> OgeomResult<Shape> {
939    let stored = if face.orientation() == ogeom_topo::Orientation::Reversed {
940        face.reversed()
941    } else {
942        face.clone()
943    };
944    let Some(data) = model
945        .node(&stored)
946        .and_then(|n| n.data().as_face())
947        .cloned()
948    else {
949        ogeom_bail!(Dangling, "face is not in this model");
950    };
951    let wires = model.children_of(&Shape::of(stored.node()))?;
952    Ok(model
953        .add_face(data, &wires)?
954        .located(face.location().clone()))
955}