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, ¤t, &mut reshape, tol)?;
205 let edges_collapsed = collapse_small_edges(model, ¤t, &mut reshape, tol)?;
206 if !reshape.is_empty() {
207 let built = reshape.apply(model, ¤t)?;
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, ¤t, &mut gaps, tol)?;
216 if !gaps.is_empty() {
217 let built = gaps.apply(model, ¤t)?;
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 ¤t,
228 &mut lacking,
229 tol.confusion() * TRIM_CAP,
230 tol,
231 )?;
232 if !lacking.is_empty() {
233 let built = lacking.apply(model, ¤t)?;
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, ¤t, &mut poles, tol)?;
243 if !poles.is_empty() {
244 let built = poles.apply(model, ¤t)?;
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, ¤t, 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, ¤t, 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, ¤t, &mut tails, tol)?;
271 if !tails.is_empty() {
272 let built = tails.apply(model, ¤t)?;
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, ¤t, &mut notches, tol)?;
280 if !notches.is_empty() {
281 let built = notches.apply(model, ¤t)?;
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, ¤t, &mut small, tol)?;
291 if !small.is_empty() {
292 let built = small.apply(model, ¤t)?;
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 ¤t,
303 &mut crossings,
304 tol.confusion() * TRIM_CAP,
305 tol,
306 )?;
307 if !crossings.is_empty() {
308 let built = crossings.apply(model, ¤t)?;
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, ¤t, &mut natural, tol)?;
315 if !natural.is_empty() {
316 let built = natural.apply(model, ¤t)?;
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(¤t)?;
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, ¤t, tol.confusion() * TRIM_CAP, tol)?;
329 let faces = explore_unique(model, ¤t, 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, ¤t, 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(¤t, 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, ¤t, 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, ¤t)?;
370 history = history.then(&built.history);
371 current = built.shape;
372 }
373 }
374
375 let tolerances_reduced = reduce_tolerances(model, ¤t, 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, ¤t, tol)?;
379 let curve_ends_stated = state_curve_ends(model, ¤t, 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, ¤t)?;
385 let after = check_kept(model, ¤t, tol, &kept)?;
386 let mut crossings_left = 0;
387 for face in explore_unique(model, ¤t, 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, ¤t)?;
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(¤t.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(¤t) {
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}