ogeom_bool/lib.rs
1//! Boolean operations.
2//!
3//! *Elsewhere:* `BOPDS`, `BOPAlgo`, `BOPTools`, `IntTools` and `BRepAlgoAPI`.
4//!
5//! The architectural insight worth preserving: **fuse, common, cut, section and
6//! split are one algorithm (general fuse) plus a selection predicate.** The
7//! pipeline is
8//!
9//! 1. a data structure of indexed sub-shapes, per-type interference lists
10//! (V/V, V/E, V/F, E/E, E/F, F/F), pave blocks and common blocks;
11//! 2. a pave filler running in strictly increasing dimension, ending in face/face
12//! intersection, section-edge construction and pcurve generation;
13//! 3. a builder that splits faces in 2D parametric space, unifies same-domain
14//! faces, rebuilds solids from face sets, and repairs tolerances;
15//! 4. filters that select from the general-fuse result.
16//!
17//! # One pipeline, any analytic face
18//!
19//! The pipeline runs on solids whose faces are any surface §7's intersectors
20//! answer for (planes, cylinders, spheres, cones, tori), with the planar
21//! case as nothing more than the case where every curve is a line. Sections
22//! come from [`intersect_surfaces`](ogeom_intersect::intersect_surfaces), exact
23//! with same-parameter pcurves where the projection has a closed form and
24//! *marched and fitted to a stated tolerance* where it does not. Paves (the
25//! points where sections meet boundary edges and each other) come from the
26//! exact curve/curve intersection. Every face is then split in its own
27//! parameter space by an arrangement of *strands*: polyline scaffolding, each
28//! naming the exact sub-curve it stands for, so the combinatorics run on
29//! polylines and the rebuilt result is exact curves, pcurves attached both
30//! sides, sewn back into shared topology by the sewing whose flipped-carry
31//! bug this crate found and §9 fixed.
32//!
33//! Pieces are classified against the other solid by the exact ray classifier
34//! and the filters select: fuse keeps what is outside, common what is inside,
35//! cut flips the tool's contribution. Same-domain contact (a piece lying
36//! *on* the other boundary) and tangential touching are refused with an
37//! error naming the deferred entry, never silently mishandled.
38
39mod arrange;
40mod bins;
41mod defeature;
42mod half_space;
43
44pub use defeature::remove_faces;
45
46use ogeom_algo::{
47 Built, Containment, History, is_shell_closed, make_edge_between, make_face_on, make_vertex,
48 make_wire, sew, shape_bounds,
49};
50use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
51use ogeom_geom::Curve2d as _;
52use ogeom_geom::Curve3d as _;
53use ogeom_geom::Surface as _;
54use ogeom_geom::{Curve, PlanarCurve, SurfaceGeometry, Transformable};
55use ogeom_math::{Point, Point2};
56use ogeom_topo::{
57 EdgeRepr, Filter, Location, Model, NodeData, Shape, ShapeType, explore, explore_unique,
58};
59
60use arrange::{
61 Strand, Traversal, assemble as arrange_pieces, inside_many, inside_many_slanted, inside_rings,
62};
63
64/// Parameter-space chord for the polyline scaffolding.
65const SCAFFOLD_CHORD: f64 = 1e-3;
66
67/// Parameter-space node snap for the arrangement.
68const PARAM_SNAP: f64 = 1e-6;
69
70// --- gathering ---------------------------------------------------------------
71
72/// An edge occurrence's identity: its node and where it is placed.
73///
74/// One node placed twice is two edges in space: a prism's far cap is its
75/// near cap moved, the same nodes under a displacement. Paves shared by
76/// node alone would split the near cap's edges wherever the far cap's were
77/// crossed. The placement is folded in as its hash, which keeps the key a
78/// plain value.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
80struct EdgeKey {
81 node: ogeom_topo::TShapeId,
82 placement: u64,
83}
84
85impl EdgeKey {
86 fn of(edge: &Shape) -> Self {
87 use core::hash::{Hash as _, Hasher as _};
88 let mut hasher = std::collections::hash_map::DefaultHasher::new();
89 edge.location().hash(&mut hasher);
90 Self {
91 node: edge.node(),
92 placement: hasher.finish(),
93 }
94 }
95
96 /// The node's index, for the debug traces.
97 fn index(self) -> u32 {
98 self.node.index()
99 }
100}
101
102/// One boundary strand source: an edge as one face uses it, with the pcurve
103/// that side of it.
104struct BoundaryEdge {
105 /// The edge occurrence's identity, for sharing paves across faces.
106 node: EdgeKey,
107 /// The curve in world space.
108 curve: Curve,
109 /// The portion the edge covers, in the curve's parameter.
110 crange: (f64, f64),
111 /// The pcurve on this face's surface.
112 pcurve: PlanarCurve,
113 /// The portion of the pcurve, mapped proportionally from `crange`.
114 prange: (f64, f64),
115 /// The other side of a seam, where this edge is one.
116 other_side: Option<(PlanarCurve, (f64, f64))>,
117 /// The radius within which the edge honestly lies: a fitted rail can
118 /// carry a few dozen microns of construction slop, and every filter that
119 /// compares this edge's curve against exact geometry widens by it.
120 tolerance: f64,
121 /// The looser of the edge's two end vertices' own radii: the doubt an
122 /// earlier junction recorded there, which the arrangement's node snap
123 /// on this face must reach.
124 ends_tolerance: f64,
125 /// Each end vertex in space with its own recorded radius: a vertex that
126 /// owns a span is a junction of this boolean too, and every strand end
127 /// inside it names it.
128 ends: [(Point, f64); 2],
129 /// A conservative box round the edge: its samples, grown by twice the
130 /// sag measured between them and by its own radius.
131 bound: ogeom_math::Aabb,
132}
133
134/// A pole: an edge that bounds a face in parameter space and collapses to
135/// a point in space.
136///
137/// A sphere's poles and a cone's apex have no curve to split, but they are
138/// half the chart's boundary; leave them out and the face's outline does
139/// not close, and nothing can be arranged inside it.
140struct PoleEdge {
141 /// Where the pole sits in space.
142 point: Point,
143 /// The chart line the pole runs along.
144 pcurve: PlanarCurve,
145 prange: (f64, f64),
146}
147
148/// One face, gathered and vetted.
149struct GFace {
150 face: Shape,
151 /// The surface in world space, placement applied.
152 surface: SurfaceGeometry,
153 /// A conservative world bound: the boundary edges' sampled extent plus
154 /// the surface's own allowance: nothing for a plane, measured sampling
155 /// slack for the ruled kinds whose rulings pin them to their boundary's
156 /// hull, most of the diagonal for anything that can genuinely bulge, a
157 /// dome past its equator. The poles join after. Gates the pair filter
158 /// and refusals; `OGEOM_BOOL_AUDIT_BOUNDS` audits its conservatism.
159 bound: ogeom_math::Aabb,
160 /// The scale the marching chord derives from: deliberately *not* the
161 /// filter box's diagonal, so the filter can tighten without silently
162 /// tightening the marcher.
163 chord_scale: f64,
164 edges: Vec<BoundaryEdge>,
165 poles: Vec<PoleEdge>,
166}
167
168/// An argument solid.
169struct GSolid {
170 solid: Shape,
171 faces: Vec<GFace>,
172}
173
174fn gather(model: &Model, solid: &Shape, tol: Tolerances) -> OgeomResult<GSolid> {
175 if model.kind_of(solid)? != ShapeType::Solid {
176 ogeom_bail!(Construction, "boolean arguments are solids");
177 }
178 for shell in explore_unique(model, solid, ShapeType::Shell)? {
179 if !is_shell_closed(model, &shell)? {
180 ogeom_bail!(Construction, "an open shell bounds no volume to operate on");
181 }
182 }
183
184 let mut faces = Vec::new();
185 for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
186 let Some(node) = model.node(&face) else {
187 ogeom_bail!(Dangling, "face is not in this model");
188 };
189 let NodeData::Face(data) = node.data() else {
190 ogeom_bail!(Construction, "face node holds no face data");
191 };
192 let Some(stored) = model.geometry().surface(data.surface) else {
193 ogeom_bail!(Dangling, "face refers to a surface not in this model");
194 };
195 let placement = face.transform(model.datums())?;
196 if (placement.scale_factor().abs() - 1.0).abs() > 1e-9 {
197 ogeom_bail!(
198 NotDone,
199 "a scaled placement changes a surface's parameterization out \
200 from under its pcurves; bake the scale before a boolean"
201 );
202 }
203 let surface = stored.transformed(&placement, tol)?;
204 let surface_id = data.surface;
205
206 let mut edges = Vec::new();
207 let mut poles = Vec::new();
208 for edge in explore_unique(model, &face, ShapeType::Edge)? {
209 let Some(edge_node) = model.node(&edge) else {
210 ogeom_bail!(Dangling, "edge is not in this model");
211 };
212 let NodeData::Edge(edge_data) = edge_node.data() else {
213 ogeom_bail!(Construction, "edge node holds no edge data");
214 };
215 let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
216 // A degenerate edge (a sphere's pole, a cone's apex) has
217 // no extent to split, but it *does* bound the chart, and a
218 // chart whose top is missing bounds nothing.
219 if let Some(EdgeRepr::PCurve {
220 curve: pc, range, ..
221 }) = edge_data.pcurve_for(surface_id, edge.location())
222 {
223 let Some(planar) = model.geometry().pcurve(*pc) else {
224 ogeom_bail!(Dangling, "pcurve is not in this model");
225 };
226 let at = explore_unique(model, &edge, ShapeType::Vertex)?;
227 let Some(point) = at
228 .first()
229 .and_then(|v| model.node(v))
230 .and_then(|n| n.data().as_vertex().map(|d| d.point))
231 else {
232 ogeom_bail!(Construction, "a pole with no vertex is nowhere");
233 };
234 let placed = edge.transform(model.datums())?.apply(point);
235 poles.push(PoleEdge {
236 point: placed,
237 pcurve: planar.clone(),
238 prange: *range,
239 });
240 }
241 continue;
242 };
243 let Some(geometry) = model.geometry().curve(*curve) else {
244 ogeom_bail!(Dangling, "curve is not in this model");
245 };
246 let world = geometry.transformed(&edge.transform(model.datums())?, tol)?;
247 let (pcurve, prange, other_side) =
248 match edge_data.pcurve_for(surface_id, edge.location()) {
249 Some(EdgeRepr::PCurve {
250 curve: pc, range, ..
251 }) => {
252 let Some(planar) = model.geometry().pcurve(*pc) else {
253 ogeom_bail!(Dangling, "pcurve is not in this model");
254 };
255 (planar.clone(), *range, None)
256 }
257 Some(EdgeRepr::Seam {
258 forward,
259 reversed,
260 range,
261 ..
262 }) => {
263 let (Some(f), Some(r)) = (
264 model.geometry().pcurve(*forward),
265 model.geometry().pcurve(*reversed),
266 ) else {
267 ogeom_bail!(Dangling, "seam pcurve is not in this model");
268 };
269 (f.clone(), *range, Some((r.clone(), *range)))
270 }
271 _ => ogeom_bail!(
272 Construction,
273 "an edge with no pcurve on its face cannot be split in \
274 that face's parameter space"
275 ),
276 };
277 let ends_tolerance = model
278 .children_of(&edge)?
279 .iter()
280 .filter_map(|v| model.node(v).and_then(|n| n.data().as_vertex()))
281 .fold(0.0_f64, |acc, d| acc.max(d.tolerance.get()));
282 // Each end's own vertex, matched to the curve's ends by position:
283 // the edge's vertex order and its curve's direction need not
284 // agree once the edge is reversed in its wire.
285 let ends = {
286 let (a, b) = (world.point_at(range.0, tol)?, world.point_at(range.1, tol)?);
287 let mut ends = [(a, tol.confusion()), (b, tol.confusion())];
288 for v in model.children_of(&edge)? {
289 if let Some(d) = model.node(&v).and_then(|n| n.data().as_vertex()) {
290 let at = v.transform(model.datums())?.apply(d.point);
291 let k = usize::from(at.distance(b) < at.distance(a));
292 ends[k].1 = ends[k].1.max(d.tolerance.get());
293 }
294 }
295 ends
296 };
297 edges.push(BoundaryEdge {
298 node: EdgeKey::of(&edge),
299 curve: world,
300 crange: *range,
301 pcurve,
302 prange,
303 other_side,
304 tolerance: edge_data.tolerance.get(),
305 ends_tolerance,
306 ends,
307 bound: ogeom_math::Aabb::EMPTY,
308 });
309 }
310 if edges.is_empty() {
311 ogeom_bail!(Construction, "a face with no boundary bounds nothing");
312 }
313
314 // A seam edge bounds the chart twice *only when this face wraps*:
315 // when its other boundary reaches both columns, as a full drum's
316 // rims do. A face that merely sits against the meridian (a sphere
317 // octant whose boundary happens to be the seam) uses one column,
318 // and feeding the far copy into the arrangement leaves a strand
319 // nothing connects to. The decision is made here, once, by chart
320 // connectivity, and everything downstream (the arrangement, the
321 // trim tests, the rebuild) inherits it.
322 let seam_indices: Vec<usize> = edges
323 .iter()
324 .enumerate()
325 .filter(|(_, e)| e.other_side.is_some())
326 .map(|(i, _)| i)
327 .collect();
328 for i in seam_indices {
329 let mut pool: Vec<Point2> = Vec::new();
330 for (j, e) in edges.iter().enumerate() {
331 if j == i {
332 continue;
333 }
334 for t in [e.prange.0, e.prange.1] {
335 pool.push(e.pcurve.point_at(t, tol)?);
336 }
337 if let Some((other, orange)) = &e.other_side {
338 for t in [orange.0, orange.1] {
339 pool.push(other.point_at(t, tol)?);
340 }
341 }
342 }
343 for p in &poles {
344 for t in [p.prange.0, p.prange.1] {
345 pool.push(p.pcurve.point_at(t, tol)?);
346 }
347 }
348 let connected = |pc: &PlanarCurve, range: (f64, f64)| -> OgeomResult<bool> {
349 for t in [range.0, range.1] {
350 let at = pc.point_at(t, tol)?;
351 if !pool.iter().any(|q| q.distance(at) <= PARAM_SNAP) {
352 return Ok(false);
353 }
354 }
355 Ok(true)
356 };
357 let e = &edges[i];
358 let primary_connects = connected(&e.pcurve, e.prange)?;
359 let (other_pc, orange) = e
360 .other_side
361 .clone()
362 .unwrap_or_else(|| (e.pcurve.clone(), e.prange));
363 let other_connects = connected(&other_pc, orange)?;
364 match (primary_connects, other_connects) {
365 // Both columns meet the rest of the boundary: the face
366 // wraps, and both belong.
367 (true, true) => {}
368 // One column is this face's; the far copy would dangle.
369 (true, false) => edges[i].other_side = None,
370 (false, true) => {
371 let e = &mut edges[i];
372 e.pcurve = other_pc;
373 e.prange = orange;
374 e.other_side = None;
375 }
376 // Neither connects: leave both, and the arrangement's own
377 // refusal names the failure as it always did.
378 (false, false) => {}
379 }
380 }
381 let mut bound = ogeom_math::Aabb::EMPTY;
382 // For a ruled surface the box will be trusted to the boundary's own
383 // hull, so the boundary's sampling slack must be measured: how far
384 // the true edge sags from the 16-chord polyline, read at each
385 // chord's midpoint and doubled for the sag's asymmetry. Nothing else
386 // uses the measurement, and the extra evaluations are priced on
387 // spline edges, so nothing else pays for it: an unruled face keeps
388 // the exact box it always had, and the exact admit set with it.
389 let ruled = matches!(
390 &surface,
391 SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_)
392 );
393 let mut slack = 0.0_f64;
394 for e in &mut edges {
395 let mut previous: Option<Point> = None;
396 let (mut own, mut sag) = (ogeom_math::Aabb::EMPTY, 0.0_f64);
397 for i in 0..=16 {
398 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
399 let t = e.crange.0 + (e.crange.1 - e.crange.0) * f64::from(i) / 16.0;
400 let p = e.curve.point_at(t, tol)?;
401 if let Some(q) = previous {
402 let step = (e.crange.1 - e.crange.0) / 32.0;
403 let mid = e.curve.point_at(t - step, tol)?;
404 let off = mid.distance(Point::midpoint(q, p)) * 2.0;
405 sag = sag.max(off);
406 own = own.with_point(mid);
407 if ruled {
408 slack = slack.max(off);
409 bound = bound.with_point(mid);
410 }
411 }
412 own = own.with_point(p);
413 bound = bound.with_point(p);
414 previous = Some(p);
415 }
416 e.bound = own.expanded(sag + e.tolerance + tol.confusion() * 1e2);
417 }
418 // A plane never bulges past its boundary. A ruled surface (cylinder,
419 // cone) cannot either: every surface point lies on a straight ruling
420 // whose ends are on the boundary, so the face sits inside its
421 // boundary's hull and only the boundary's own sampling slack is owed.
422 // Anything else may genuinely bulge (a dome past its equator) and
423 // keeps most of its own diagonal as allowance. The audit behind
424 // OGEOM_BOOL_AUDIT_BOUNDS holds every arm to conservatism.
425 let bulge = match &surface {
426 SurfaceGeometry::Plane(_) => 0.0,
427 SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_) => slack,
428 _ => bound.diagonal() * 0.75,
429 };
430 // The scale the marching chord is derived from, decoupled from the
431 // filter box. It reproduces exactly what the heuristic was tuned
432 // against (the diagonal as the blanket three-quarter bulge left it)
433 // because the chord is a tolerance, not a bound: tightening the
434 // filter must not silently tighten the marcher, which is exactly
435 // what a single box feeding both does.
436 let margin = tol.confusion() * 1e2;
437 let chord_scale = match &surface {
438 SurfaceGeometry::Plane(_) => bound.expanded(margin).diagonal(),
439 _ => bound.expanded(bound.diagonal() * 0.75 + margin).diagonal(),
440 };
441 // A pole bounds the chart with no edge to sample: a cone drilled to
442 // its apex reaches the apex, and a bound that omits it would let the
443 // filter drop a pair the apex genuinely meets. It joins *after* the
444 // bulge is taken from the boundary's own diagonal: the pole is an
445 // exact point, and letting it stretch the diagonal would inflate a
446 // dome's allowance by its own height over again.
447 let mut bound = bound.expanded(bulge);
448 for pole in &poles {
449 bound = bound.with_point(pole.point);
450 }
451 let bound = bound.expanded(tol.confusion() * 1e2);
452 faces.push(GFace {
453 poles,
454 face,
455 surface,
456 bound,
457 chord_scale,
458 edges,
459 });
460 }
461 if faces.is_empty() {
462 ogeom_bail!(Construction, "a solid with no faces bounds nothing");
463 }
464 Ok(GSolid {
465 solid: solid.clone(),
466 faces,
467 })
468}
469
470/// A parameter brought onto the turn its edge actually covers.
471///
472/// A periodic curve's own domain and an edge's range over it need not agree
473/// on which turn to count from: a sphere's seam runs its half meridian over
474/// `[-π/2, π/2]`, while every crossing found on it comes back in `[0, 2π)`.
475/// Left alone, a crossing at latitude `-0.96` arrives as `5.32`, sits outside
476/// the range, and is discarded, so the seam never splits where a section
477/// genuinely meets it, and the arrangement finds the chain hanging.
478fn onto_range(t: f64, curve: &Curve, range: (f64, f64), tol: Tolerances) -> f64 {
479 if !curve.is_periodic() {
480 return t;
481 }
482 let (a, b) = curve.domain();
483 let period = b - a;
484 if period <= 0.0 {
485 return t;
486 }
487 for k in [0.0, -1.0, 1.0, -2.0, 2.0] {
488 let shifted = period.mul_add(k, t);
489 if shifted >= range.0 - tol.parametric() && shifted <= range.1 + tol.parametric() {
490 return shifted;
491 }
492 }
493 t
494}
495
496/// The part of an overlap that falls within the second curve's own bounded
497/// range, stated in the *first* curve's parameter.
498///
499/// An overlap is between two curves; an edge covers only part of its curve.
500/// The correspondence the overlap states is affine, so the edge's range
501/// carries across as an interval, and on a periodic curve it carries across
502/// up to whole turns, so the shift that meets the overlap is the one meant.
503/// `None` where the edge's own stretch and the overlap do not meet.
504fn overlap_within(
505 overlap: &ogeom_intersect::Overlap,
506 range: (f64, f64),
507 curve: &Curve,
508 tol: Tolerances,
509) -> Option<(f64, f64)> {
510 let ordered = |r: (f64, f64)| if r.0 <= r.1 { r } else { (r.1, r.0) };
511 let span_a = overlap.on_a.1 - overlap.on_a.0;
512 let span_b = overlap.on_b.1 - overlap.on_b.0;
513 if span_b.abs() <= f64::MIN_POSITIVE {
514 return None;
515 }
516 let to_a = |t: f64| overlap.on_a.0 + span_a * (t - overlap.on_b.0) / span_b;
517 let (wlo, whi) = ordered((to_a(range.0), to_a(range.1)));
518 let (lo, hi) = ordered(overlap.on_a);
519 let period = if curve.is_periodic() {
520 let (a, b) = curve.domain();
521 // The turn measured in the *first* curve's parameter, which is where
522 // the intersection is being taken.
523 (b - a) * (span_a / span_b).abs()
524 } else {
525 0.0
526 };
527 let mut best: Option<(f64, f64)> = None;
528 for k in [0.0, 1.0, -1.0, 2.0, -2.0] {
529 let candidate = (
530 lo.max(period.mul_add(k, wlo)),
531 hi.min(period.mul_add(k, whi)),
532 );
533 if candidate.1 - candidate.0 > best.map_or(tol.parametric(), |(x, y)| y - x) {
534 best = Some(candidate);
535 }
536 if period == 0.0 {
537 break;
538 }
539 }
540 best
541}
542
543/// A parameter carried proportionally from one range to another.
544fn rescale(t: f64, from: (f64, f64), to: (f64, f64)) -> f64 {
545 let span = from.1 - from.0;
546 if span.abs() <= f64::MIN_POSITIVE {
547 return to.0;
548 }
549 to.0 + (to.1 - to.0) * (t - from.0) / span
550}
551
552/// The pcurve's course over a sub-range of the *curve's* parameters.
553/// The stretches of `curve` over `crange` that lie along the edge `e`
554/// within the pair's honesty, as overlaps with the edge's own parameters at
555/// their ends: the measured twin of the intersector's closed-form
556/// coincidence, for a fitted curve on an exact one.
557fn measured_overlaps(
558 curve: &Curve,
559 crange: (f64, f64),
560 tolerance: f64,
561 e: &BoundaryEdge,
562 tol: Tolerances,
563) -> OgeomResult<Vec<ogeom_intersect::Overlap>> {
564 const SAMPLES: usize = 48;
565 let width = (tolerance.max(e.tolerance) * 2.0).max(tol.confusion() * 1e3);
566 let mut near: Vec<bool> = Vec::with_capacity(SAMPLES + 1);
567 for i in 0..=SAMPLES {
568 #[allow(clippy::cast_precision_loss)]
569 let t = crange.0 + (crange.1 - crange.0) * (i as f64) / (SAMPLES as f64);
570 let p = curve.point_at(t, tol)?;
571 near.push(distance_to_edge_curve(&e.curve, e.crange, p, tol)? <= width);
572 }
573 let mut out = Vec::new();
574 let mut i = 0;
575 while i <= SAMPLES {
576 if !near[i] {
577 i += 1;
578 continue;
579 }
580 let start = i;
581 while i < SAMPLES && near[i + 1] {
582 i += 1;
583 }
584 let end = i;
585 i += 1;
586 // Three samples along is a stretch; fewer is a crossing's blur.
587 if end - start < 2 {
588 continue;
589 }
590 #[allow(clippy::cast_precision_loss)]
591 let at = |k: usize| crange.0 + (crange.1 - crange.0) * (k as f64) / (SAMPLES as f64);
592 // Each end refined by bisection into the neighbouring sample gap.
593 let refine = |inside: f64, outside: f64| -> OgeomResult<f64> {
594 let (mut a, mut b) = (inside, outside);
595 for _ in 0..24 {
596 let m = f64::midpoint(a, b);
597 let p = curve.point_at(m, tol)?;
598 if distance_to_edge_curve(&e.curve, e.crange, p, tol)? <= width {
599 a = m;
600 } else {
601 b = m;
602 }
603 }
604 Ok(a)
605 };
606 let lo = if start == 0 {
607 at(0)
608 } else {
609 refine(at(start), at(start - 1))?
610 };
611 let hi = if end == SAMPLES {
612 at(SAMPLES)
613 } else {
614 refine(at(end), at(end + 1))?
615 };
616 if hi - lo <= tol.parametric() {
617 continue;
618 }
619 let on_edge = |t: f64| -> OgeomResult<f64> {
620 let p = curve.point_at(t, tol)?;
621 let foot = ogeom_algo::project_on_curve(&e.curve, p, 64, tol)?;
622 Ok(onto_range(foot.parameter, &e.curve, e.crange, tol))
623 };
624 if *DEBUG_WIRE {
625 eprintln!(
626 "CONTACT along edge {} measured over ({lo:.6}, {hi:.6}) within {width:.2e}",
627 e.node.index()
628 );
629 }
630 out.push(ogeom_intersect::Overlap {
631 on_a: (lo, hi),
632 on_b: (on_edge(lo)?, on_edge(hi)?),
633 });
634 }
635 Ok(out)
636}
637
638fn pcurve_polyline(
639 pcurve: &PlanarCurve,
640 prange: (f64, f64),
641 crange: (f64, f64),
642 sub: (f64, f64),
643 surface: &SurfaceGeometry,
644 tol: Tolerances,
645) -> OgeomResult<Vec<Point2>> {
646 let lo = rescale(sub.0, crange, prange);
647 let hi = rescale(sub.1, crange, prange);
648 // Enough samples that the first step approximates the tangent and the
649 // scanline interior test has a faithful outline. Straight pcurves get
650 // two points; everything else a fixed fine sampling.
651 let mut count = match pcurve {
652 PlanarCurve::Line(_) => 1,
653 _ => {
654 let span = (hi - lo).abs().max(SCAFFOLD_CHORD);
655 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
656 let n = (span / SCAFFOLD_CHORD).sqrt().ceil() as usize;
657 n.clamp(8, 256)
658 }
659 };
660 // And never a step [`unwrap_polyline`] could read as a period jump. A
661 // straight pcurve is faithful at two points (a coaxial rim's image on
662 // a cylinder is exactly a line across the chart), but one that crosses
663 // more than half the turn looks to that reader like a wrap, and the
664 // strand comes back running the complementary way: a bore's mouth
665 // blended where the mouth circle starts anywhere but the wall's own
666 // seam. The chart's span decides the count, not the parameter's.
667 let ((ua, ub), (va, vb)) = surface.domain();
668 let (first, last) = (pcurve.point_at(lo, tol)?, pcurve.point_at(hi, tol)?);
669 for (periodic, period, reach) in [
670 (surface.is_periodic_u(), ub - ua, (last.x - first.x).abs()),
671 (surface.is_periodic_v(), vb - va, (last.y - first.y).abs()),
672 ] {
673 if !periodic || period <= 0.0 || !reach.is_finite() {
674 continue;
675 }
676 let steps = (reach / (period / 3.0)).ceil();
677 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
678 let steps = steps.clamp(1.0, 4096.0) as usize;
679 count = count.max(steps);
680 }
681 let mut out = Vec::with_capacity(count + 1);
682 for i in 0..=count {
683 #[allow(clippy::cast_precision_loss)]
684 let t = lo + (hi - lo) * i as f64 / count as f64;
685 out.push(pcurve.point_at(t, tol)?);
686 }
687 Ok(out)
688}
689
690// --- the filler --------------------------------------------------------------
691
692/// One section curve between a face of `a` and a face of `b`.
693struct SectionRec {
694 curve: Curve,
695 /// The pcurve on the A face's surface, sharing the curve's parameter.
696 pc_a: PlanarCurve,
697 /// The same on the B face.
698 pc_b: PlanarCurve,
699 face_a: usize,
700 face_b: usize,
701 closed: bool,
702 /// How far the curve may sit from the true intersection: zero for an
703 /// exact section, the trace-plus-fit budget for a marched one. Crossing
704 /// filters widen their acceptance by this, or a fitted section would
705 /// never register against the edges it genuinely meets.
706 tolerance: f64,
707}
708
709/// The parameter intervals of a contact curve over which *both* faces
710/// actually reach it.
711///
712/// Two surfaces touch along the whole of their contact; two faces touch
713/// along whatever part of it their trims both hold. That part is found by
714/// sampling (sixty-four stations along the curve, each asked of both
715/// charts) rather than by intersecting the contact with the boundary
716/// edges, because a contact meets those boundaries tangentially too and the
717/// crossing finder is the wrong instrument for it. The cost of sampling is
718/// the usual one: a stretch shorter than a station can be missed, and an
719/// endpoint is placed within a station of the truth.
720fn contact_intervals(
721 fused: &GeneralFused,
722 contact: &TangentRec,
723 tol: Tolerances,
724) -> OgeomResult<Vec<(f64, f64)>> {
725 let outline = |face: &GFace| -> OgeomResult<Vec<Vec<Point2>>> {
726 let mut lines = Vec::new();
727 for e in &face.edges {
728 lines.push(pcurve_polyline(
729 &e.pcurve,
730 e.prange,
731 e.crange,
732 e.crange,
733 &face.surface,
734 tol,
735 )?);
736 }
737 Ok(lines)
738 };
739 let rings_a = outline(&fused.a.faces[contact.face_a])?;
740 let rings_b = outline(&fused.b.faces[contact.face_b])?;
741 let refs_a: Vec<&[Point2]> = rings_a.iter().map(Vec::as_slice).collect();
742 let refs_b: Vec<&[Point2]> = rings_b.iter().map(Vec::as_slice).collect();
743
744 const STATIONS: usize = 64;
745 let domain = contact.curve.domain();
746 let span = domain.1 - domain.0;
747 let mut runs: Vec<(f64, f64)> = Vec::new();
748 let mut open: Option<f64> = None;
749 for k in 0..=STATIONS {
750 #[expect(
751 clippy::cast_precision_loss,
752 reason = "a station index, far below the mantissa"
753 )]
754 let t = span.mul_add(k as f64 / STATIONS as f64, domain.0);
755 // A periodic chart's parameters run out past its own window; the
756 // trim test is only meaningful once they are folded back into it.
757 let held = matches!(
758 (
759 contact.pc_a.point_at(t, tol),
760 contact.pc_b.point_at(t, tol)
761 ),
762 (Ok(pa), Ok(pb))
763 if arrange::inside_many(
764 &refs_a,
765 fold_point_into_chart(pa, &fused.a.faces[contact.face_a].surface),
766 ) && arrange::inside_many(
767 &refs_b,
768 fold_point_into_chart(pb, &fused.b.faces[contact.face_b].surface),
769 )
770 );
771 match (held, open) {
772 (true, None) => open = Some(t),
773 (false, Some(from)) => {
774 if t - from > tol.parametric() {
775 runs.push((from, t));
776 }
777 open = None;
778 }
779 _ => {}
780 }
781 }
782 if let Some(from) = open
783 && domain.1 - from > tol.parametric()
784 {
785 runs.push((from, domain.1));
786 }
787 Ok(runs)
788}
789
790/// One curve along which two faces *touch* without crossing.
791///
792/// A contact carries no boundary parity (neither face passes through the
793/// other), so it takes no part in the arrangement or the classification.
794/// It is still a curve that exists on the result, and a section view
795/// through a tangency has to show it, so it is carried alongside.
796struct TangentRec {
797 curve: Curve,
798 pc_a: PlanarCurve,
799 pc_b: PlanarCurve,
800 face_a: usize,
801 face_b: usize,
802}
803
804/// One kept sub-range of one section.
805/// A strand's tolerance as a junction may trust it: a fitted section whose
806/// trace failed reports a budget of metres, and a weld that believed it
807/// would join every vertex of the model. Nothing this pipeline fits is
808/// honestly looser than ten thousand confusions.
809fn honest(tolerance: f64, tol: Tolerances) -> f64 {
810 tolerance.min(tol.confusion() * 1e4)
811}
812
813/// One split an edge is asked for, with how honestly it can be placed.
814#[derive(Debug, Clone, Copy)]
815struct Pave {
816 /// The parameter on the edge's curve.
817 t: f64,
818 /// How far the strand that asked for it may honestly sit from the exact
819 /// junction: its own tolerance, and its tangential doubt where the
820 /// crossing was a touch.
821 honesty: f64,
822}
823
824/// Paves the edge cannot tell apart, as one junction each.
825///
826/// A tolerant rail meeting a wedge's faces near one corner collects a
827/// cluster of crossings inside its own stated radius, and a fitted section
828/// and a fitted contact asking for the same corner land a few microns
829/// apart. Split at each, the edge shatters into dust no weld downstream can
830/// rejoin. So consecutive paves whose gap along the edge in space is within
831/// the edge's honesty or either pave's own are one cluster, its first pave
832/// speaking for it.
833#[derive(Debug, Clone, Copy)]
834struct PaveCluster {
835 /// The representative parameter: the cluster's first pave.
836 t: f64,
837 /// Where it sits.
838 at: Point,
839 /// How far the cluster's members reach from the representative.
840 span: f64,
841 /// The loosest honesty among the members.
842 honesty: f64,
843 /// How many paves the cluster holds.
844 members: usize,
845}
846
847fn cluster_paves(
848 curve: &Curve,
849 crange: (f64, f64),
850 edge_tolerance: f64,
851 paves: &[Pave],
852 tol: Tolerances,
853) -> OgeomResult<Vec<PaveCluster>> {
854 let mut ts: Vec<Pave> = paves
855 .iter()
856 .copied()
857 .filter(|p| p.t > crange.0 + tol.parametric() && p.t < crange.1 - tol.parametric())
858 .collect();
859 ts.sort_by(|x, y| x.t.partial_cmp(&y.t).unwrap_or(core::cmp::Ordering::Equal));
860 ts.dedup_by(|x, y| {
861 if (x.t - y.t).abs() <= tol.parametric() {
862 y.honesty = y.honesty.max(x.honesty);
863 true
864 } else {
865 false
866 }
867 });
868 let floor = edge_tolerance.max(tol.confusion() * 10.0);
869 // A pave within the edge's honesty of its own end *is* the end: split
870 // there, the sliver between them is dust one face keeps and the
871 // face across the edge drops, and the sew finds it used once.
872 let ends = [
873 curve.point_at(crange.0, tol)?,
874 curve.point_at(crange.1, tol)?,
875 ];
876 let mut clusters: Vec<PaveCluster> = Vec::new();
877 let mut prev: Option<(Point, f64)> = None;
878 for pave in ts {
879 let at = curve.point_at(pave.t, tol)?;
880 if ends
881 .iter()
882 .any(|e| e.distance(at) <= floor.max(pave.honesty))
883 {
884 continue;
885 }
886 let joined = prev.is_some_and(|(held, honesty): (Point, f64)| {
887 held.distance(at) <= floor.max(honesty).max(pave.honesty)
888 });
889 if joined && let Some(cluster) = clusters.last_mut() {
890 cluster.span = cluster.span.max(cluster.at.distance(at));
891 cluster.honesty = cluster.honesty.max(pave.honesty);
892 cluster.members += 1;
893 } else {
894 clusters.push(PaveCluster {
895 t: pave.t,
896 at,
897 span: 0.0,
898 honesty: pave.honesty,
899 members: 1,
900 });
901 }
902 prev = Some((at, pave.honesty));
903 }
904 Ok(clusters)
905}
906
907#[derive(Clone)]
908struct SectionPiece {
909 section: usize,
910 range: (f64, f64),
911 /// Whether this sub-range already *is* boundary on the A face, the B
912 /// face, or neither. A piece that is boundary on one side is still the
913 /// splitting curve on the other, and only the face it duplicates leaves
914 /// it out.
915 hugs: [bool; 2],
916 /// Set when the piece was admitted by a hug: a split of the other face
917 /// along this edge of the hugging face: (edge node, target from A,
918 /// target face). Two sections hugging one edge onto one face are the
919 /// same split, kept once.
920 hug_key: Option<(EdgeKey, bool, usize)>,
921}
922
923/// One boundary edge of one argument's face, lying in a face of the other
924/// argument's own surface: the splitting curve same-domain contact
925/// contributes, since coincident surfaces have no section curve to offer.
926struct ContactRec {
927 /// The owner's world curve and the sub-range its edge covers.
928 curve: Curve,
929 crange: (f64, f64),
930 /// The curve spoken in the *target* face's chart, over its own window,
931 /// mapped proportionally from `crange` exactly as an edge's own stored
932 /// pcurve is.
933 pcurve: PlanarCurve,
934 prange: (f64, f64),
935 /// The owner edge's node, whose paves this record shares.
936 node: EdgeKey,
937 /// The owner edge's own tolerance: how far its curve may honestly sit
938 /// from the exact geometry it meets, which is how far a crossing filter
939 /// must reach to see a fitted rail cross an exact seam.
940 tolerance: f64,
941 /// The target: which argument's face list, and which face.
942 target_from_a: bool,
943 target_face: usize,
944}
945
946/// A same-domain contact edge's image in the shared surface's chart, where
947/// no closed form exists: fitted by projection into the target's chart, or
948/// (where the edge overhangs the target's own window, a blend's leg on a
949/// spline host continued past the face) into the owner's, which is the
950/// same chart wherever an extension kept its parent's parameters. The
951/// owner's image is trusted only where lifting it through the *target*
952/// lands back on the edge, sampled over the run the target's window holds.
953///
954/// `None` for an edge lying wholly outside the target's window, which the
955/// target face cannot meet.
956///
957/// # Errors
958///
959/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if neither
960/// chart holds the edge.
961fn projected_into_shared_chart(
962 curve: &Curve,
963 range: (f64, f64),
964 owner: &SurfaceGeometry,
965 target: &SurfaceGeometry,
966 tol: Tolerances,
967) -> OgeomResult<Option<PlanarCurve>> {
968 let against_target = ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, target, tol);
969 if let Ok(fitted) = &against_target
970 && fitted.2
971 {
972 return Ok(Some(fitted.0.clone()));
973 }
974 let Ok(on_owner) = ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, owner, tol)
975 else {
976 ogeom_bail!(
977 NotDone,
978 "same-domain contact whose edge could not be projected into the \
979 shared surface's chart: {}",
980 against_target
981 .err()
982 .map_or_else(String::new, |e| e.to_string())
983 );
984 };
985 let ((ua, ub), (va, vb)) = target.domain();
986 let mut checked = 0usize;
987 for i in 0..=16 {
988 let t = range.0 + (range.1 - range.0) * f64::from(i) / 16.0;
989 let uv = on_owner.0.point_at(t, tol)?;
990 if uv.x < ua || uv.x > ub || uv.y < va || uv.y > vb {
991 continue;
992 }
993 let lifted = target.point_at(uv.x, uv.y, tol)?;
994 if lifted.distance(curve.point_at(t, tol)?) > tol.confusion() * 1e4 {
995 ogeom_bail!(
996 NotDone,
997 "same-domain contact whose edge overhangs the shared surface's \
998 window, on a chart the two surfaces do not share"
999 );
1000 }
1001 checked += 1;
1002 }
1003 if checked == 0 {
1004 return Ok(None);
1005 }
1006 Ok(Some(on_owner.0))
1007}
1008
1009/// Whether two surfaces are the *identical chart*: the same
1010/// parameterization, frame and all, not merely the same point set.
1011///
1012/// The five analytics only: a fitted surface never qualifies, because two
1013/// independent fits of one geometry agree nowhere in parameter space. This
1014/// is what lets a stored pcurve stand in for a closed-form projection in the
1015/// same-domain melt: on the identical chart, the owner's pcurve already is
1016/// the projection.
1017fn same_chart(a: &SurfaceGeometry, b: &SurfaceGeometry, tol: Tolerances) -> bool {
1018 use SurfaceGeometry as S;
1019 let frames = |fa: ogeom_math::Frame, fb: ogeom_math::Frame| -> bool {
1020 fa.origin().distance(fb.origin()) <= tol.confusion()
1021 && fa.z().vector().dot(fb.z().vector()) >= 1.0 - tol.angular()
1022 && fa.x().vector().dot(fb.x().vector()) >= 1.0 - tol.angular()
1023 };
1024 match (a, b) {
1025 (S::Plane(x), S::Plane(y)) => frames(x.plane().frame(), y.plane().frame()),
1026 (S::Cylinder(x), S::Cylinder(y)) => {
1027 frames(x.cylinder().frame(), y.cylinder().frame())
1028 && (x.cylinder().radius() - y.cylinder().radius()).abs() <= tol.confusion()
1029 }
1030 (S::Cone(x), S::Cone(y)) => {
1031 frames(x.cone().frame(), y.cone().frame())
1032 && (x.cone().reference_radius() - y.cone().reference_radius()).abs()
1033 <= tol.confusion()
1034 && (x.cone().half_angle() - y.cone().half_angle()).abs() <= tol.angular()
1035 }
1036 (S::Sphere(x), S::Sphere(y)) => {
1037 frames(x.sphere().frame(), y.sphere().frame())
1038 && (x.sphere().radius() - y.sphere().radius()).abs() <= tol.confusion()
1039 }
1040 (S::Torus(x), S::Torus(y)) => {
1041 frames(x.torus().frame(), y.torus().frame())
1042 && (x.torus().major_radius() - y.torus().major_radius()).abs() <= tol.confusion()
1043 && (x.torus().minor_radius() - y.torus().minor_radius()).abs() <= tol.confusion()
1044 }
1045 // Two patches are one chart only as one patch: the same knots and
1046 // the same net, to the bit: a blend's leg built on the host's own
1047 // patch, widened in place and shared. Any other pair of patches
1048 // that merely coincides is not.
1049 (S::BSpline(x), S::BSpline(y)) => x == y,
1050 _ => false,
1051 }
1052}
1053
1054/// What a face's arrangement strand stands for.
1055#[derive(Clone)]
1056enum Tag {
1057 /// A sub-range of boundary edge `edge` (index into the face's edges).
1058 Boundary { edge: usize, range: (f64, f64) },
1059 /// A sub-range of a global section.
1060 Section { section: usize, range: (f64, f64) },
1061 /// A sub-range of a global contact edge: another face's boundary edge
1062 /// lying in this face's own surface, splitting it.
1063 Contact { contact: usize, range: (f64, f64) },
1064 /// A sub-range of a pole of the face's own chart. The edge is a point in
1065 /// space whatever the range says; the range is where it runs in the
1066 /// chart, which is the only place it has length.
1067 Pole { pole: usize, range: (f64, f64) },
1068}
1069
1070/// Where a piece stands relative to the other solid.
1071///
1072/// `In` and `Out` are the classifier's words. The two `On` states split the
1073/// case the classifier cannot decide alone: a piece lying on the other
1074/// boundary bounds material on one side here and one side there, and whether
1075/// those sides agree (outward normals aligned) or oppose is what every
1076/// operation's filter turns on.
1077#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1078enum PieceState {
1079 In,
1080 Out,
1081 /// On the other boundary, outward normals aligned: both solids' material
1082 /// on the same side. One copy of the piece bounds the union and the
1083 /// intersection alike.
1084 OnAligned,
1085 /// On the other boundary, outward normals opposed: material on both
1086 /// sides. The contact is interior to the union and vanishes from it.
1087 OnOpposed,
1088}
1089
1090/// Fold a param-space polyline into a periodic surface's chart, by one
1091/// constant offset per axis.
1092///
1093/// A section's pcurve is *unwrapped* across a seam: continuous, and allowed
1094/// to leave the stated domain, because that is what crossing a seam is. The
1095/// arrangement lives in one chart, and the filler has already split every
1096/// section at its seam crossings, so each strand spans at most one chart
1097/// width and a single period shift per axis brings it home. The shift is
1098/// chosen by the strand's midpoint, so endpoints sitting exactly on the
1099/// chart's edge stay on whichever side the strand's body is.
1100/// A point in a polyline's *interior*: its half-way member, except that a
1101/// straight strand is two points and its half-way member is an endpoint,
1102/// which may sit exactly on a seam or a trim. The chord midpoint is on the
1103/// curve for a straight image and strictly inside either way.
1104fn interior_of(line: &[Point2]) -> Point2 {
1105 if line.len() == 2 {
1106 Point2::new(
1107 f64::midpoint(line[0].x, line[1].x),
1108 f64::midpoint(line[0].y, line[1].y),
1109 )
1110 } else {
1111 line[line.len() / 2]
1112 }
1113}
1114
1115fn fold_into_chart(line: &mut [Point2], surface: &SurfaceGeometry) {
1116 let ((ua, ub), (va, vb)) = surface.domain();
1117 if line.is_empty() {
1118 return;
1119 }
1120 let mid = interior_of(line);
1121 if surface.is_periodic_u() {
1122 let span = ub - ua;
1123 if span > 0.0 {
1124 let shift = (ua + (mid.x - ua).rem_euclid(span)) - mid.x;
1125 for p in line.iter_mut() {
1126 p.x += shift;
1127 }
1128 }
1129 }
1130 if surface.is_periodic_v() {
1131 let span = vb - va;
1132 if span > 0.0 {
1133 let shift = (va + (mid.y - va).rem_euclid(span)) - mid.y;
1134 for p in line.iter_mut() {
1135 p.y += shift;
1136 }
1137 }
1138 }
1139}
1140
1141/// The shifts by whole periods worth trying on a chart point after its fold,
1142/// nearest first: none, then one period along each periodic direction.
1143fn period_shifts(surface: &SurfaceGeometry) -> Vec<(f64, f64)> {
1144 let ((ua, ub), (va, vb)) = surface.domain();
1145 let mut out = vec![(0.0, 0.0)];
1146 if surface.is_periodic_u() && ub > ua {
1147 out.push((ub - ua, 0.0));
1148 out.push((ua - ub, 0.0));
1149 }
1150 if surface.is_periodic_v() && vb > va {
1151 out.push((0.0, vb - va));
1152 out.push((0.0, va - vb));
1153 }
1154 out
1155}
1156
1157/// A chart point folded to the side of the face's seam its trim is on.
1158///
1159/// The surface's fold puts a point in the period starting at the surface's
1160/// own zero, which is where a face's trim is only when its seam runs there.
1161/// A patch whose seam was cut elsewhere (a band opened along the widest gap
1162/// its mesh left, running diagonally round) covers a period starting at the
1163/// seam, and a point just short of it belongs a period on. The fold is kept
1164/// where it lands inside the trim, or where no shift does.
1165fn fold_inside(p: Point2, surface: &SurfaceGeometry, trim: &[&[Point2]]) -> Point2 {
1166 let folded = fold_point_into_chart(p, surface);
1167 if trim.is_empty() {
1168 return folded;
1169 }
1170 period_shifts(surface)
1171 .into_iter()
1172 .map(|(du, dv)| Point2::new(folded.x + du, folded.y + dv))
1173 .find(|q| inside_many(trim, *q))
1174 .unwrap_or(folded)
1175}
1176
1177/// A strand folded into the chart as a whole, to the side of the face's
1178/// seam its interior lies inside the trim on, as [`fold_inside`] does for
1179/// a point.
1180fn fold_line_inside(line: &mut [Point2], surface: &SurfaceGeometry, trim: &[&[Point2]]) {
1181 fold_into_chart(line, surface);
1182 if line.is_empty() || trim.is_empty() {
1183 return;
1184 }
1185 let mid = interior_of(line);
1186 if let Some((du, dv)) = period_shifts(surface)
1187 .into_iter()
1188 .find(|(du, dv)| inside_many(trim, Point2::new(mid.x + du, mid.y + dv)))
1189 {
1190 for p in line.iter_mut() {
1191 p.x += du;
1192 p.y += dv;
1193 }
1194 }
1195}
1196
1197/// A face's boundary in its chart, each edge's image sampled, both sides of
1198/// a seam: enough to tell which side of the seam a point is on.
1199fn face_trim_lines(face: &GFace, tol: Tolerances) -> Vec<Vec<Point2>> {
1200 let sample = |pcurve: &PlanarCurve, range: (f64, f64)| -> Vec<Point2> {
1201 (0..=16)
1202 .filter_map(|k| {
1203 pcurve
1204 .point_at(range.0 + (range.1 - range.0) * f64::from(k) / 16.0, tol)
1205 .ok()
1206 })
1207 .collect()
1208 };
1209 let mut out = Vec::new();
1210 for e in &face.edges {
1211 out.push(sample(&e.pcurve, e.prange));
1212 if let Some((other, range)) = &e.other_side {
1213 out.push(sample(other, *range));
1214 }
1215 }
1216 out
1217}
1218
1219/// Remove period tears from a sampled polyline, axis by axis.
1220fn unwrap_polyline(line: &mut [Point2], surface: &SurfaceGeometry) {
1221 let ((ua, ub), (va, vb)) = surface.domain();
1222 let spans = (
1223 if surface.is_periodic_u() {
1224 ub - ua
1225 } else {
1226 0.0
1227 },
1228 if surface.is_periodic_v() {
1229 vb - va
1230 } else {
1231 0.0
1232 },
1233 );
1234 for i in 1..line.len() {
1235 if spans.0 > 0.0 {
1236 while line[i].x - line[i - 1].x > spans.0 * 0.5 {
1237 line[i].x -= spans.0;
1238 }
1239 while line[i].x - line[i - 1].x < -spans.0 * 0.5 {
1240 line[i].x += spans.0;
1241 }
1242 }
1243 if spans.1 > 0.0 {
1244 while line[i].y - line[i - 1].y > spans.1 * 0.5 {
1245 line[i].y -= spans.1;
1246 }
1247 while line[i].y - line[i - 1].y < -spans.1 * 0.5 {
1248 line[i].y += spans.1;
1249 }
1250 }
1251 }
1252}
1253
1254/// Fold a point into a periodic surface's chart.
1255fn fold_point_into_chart(p: Point2, surface: &SurfaceGeometry) -> Point2 {
1256 let mut one = [p];
1257 fold_into_chart(&mut one, surface);
1258 one[0]
1259}
1260
1261/// A sub-range of a closed curve, brought into its domain.
1262///
1263/// The filler split every wrap interval at the domain end, so a piece fits
1264/// within one period; the fold of its start may still land the end a hair
1265/// past the domain, which clamps.
1266fn folded_range(range: (f64, f64), domain: (f64, f64), closed: bool) -> (f64, f64) {
1267 if !closed {
1268 return range;
1269 }
1270 let f0 = fold(range.0, domain);
1271 let f1 = (f0 + (range.1 - range.0)).min(domain.1);
1272 (f0, f1)
1273}
1274
1275/// Fold a parameter into a closed curve's domain.
1276fn fold(t: f64, domain: (f64, f64)) -> f64 {
1277 let span = domain.1 - domain.0;
1278 if span <= 0.0 {
1279 return domain.0;
1280 }
1281 domain.0 + (t - domain.0).rem_euclid(span)
1282}
1283
1284/// The parameter to evaluate a section at: folded where the curve is closed
1285/// and its pieces may run past the domain end, left alone where it is not.
1286///
1287/// An open section's own end *is* the domain end, and folding it lands on the
1288/// domain start instead: the far end of the curve. On a plane's section
1289/// through a ball's poles, where the marcher hands back two open half circles
1290/// each running pole to pole, that is the difference between an edge bounded
1291/// by the pole it reaches and one bounded by the pole on the other side.
1292fn at_param(t: f64, domain: (f64, f64), closed: bool) -> f64 {
1293 if closed { fold(t, domain) } else { t }
1294}
1295
1296/// The sections between two gathered solids, with the paves they put on
1297/// boundary edges.
1298#[allow(clippy::type_complexity)]
1299fn fill(
1300 ga: &GSolid,
1301 gb: &GSolid,
1302 admit_all: bool,
1303 tol: Tolerances,
1304) -> OgeomResult<(
1305 Vec<SectionRec>,
1306 Vec<SectionPiece>,
1307 Vec<ContactRec>,
1308 Vec<TangentRec>,
1309 Vec<Vec<(f64, f64)>>,
1310 std::collections::HashMap<EdgeKey, Vec<Pave>>,
1311 Vec<Vec<usize>>,
1312 Vec<Vec<usize>>,
1313 Vec<Junction>,
1314)> {
1315 use ogeom_intersect::{
1316 CurveCurveOptions, IntersectOptions, SurfaceIntersection, intersect_curves,
1317 intersect_surfaces,
1318 };
1319 let mut sections: Vec<SectionRec> = Vec::new();
1320 let mut contacts: Vec<ContactRec> = Vec::new();
1321 let mut tangents: Vec<TangentRec> = Vec::new();
1322 let mut same_pairs: Vec<(usize, usize)> = Vec::new();
1323 // Marched sections are fitted; the fit is driven below the confusion
1324 // tolerance so a fitted curve meets edges, vertices and the mesh welder
1325 // on the same terms as an exact one. The budget each still carries is
1326 // recorded per section and widens the crossing filters.
1327 for (ia, fa) in ga.faces.iter().enumerate() {
1328 for (ib, fb) in gb.faces.iter().enumerate() {
1329 let admitted = fa.bound.intersects(&fb.bound);
1330 if !admitted && !admit_all {
1331 // The faces cannot meet, whatever their surfaces do.
1332 continue;
1333 }
1334 let scale = fa.chord_scale.min(fb.chord_scale);
1335 let chord = (scale * 1e-7).max(tol.confusion() * 0.5);
1336 let options = IntersectOptions {
1337 // The fit budget scales with the chord: a section against a
1338 // fitted surface cannot honestly land closer than the
1339 // geometry it cuts, and whatever it carries is stated on
1340 // the record and widens every filter downstream.
1341 tolerance: chord,
1342 marching: ogeom_intersect::Marching {
1343 chord,
1344 ..ogeom_intersect::Marching::default()
1345 },
1346 };
1347 // Coincidence is asked before the intersector is, but only where
1348 // the closed forms have already declined the pair. The marcher
1349 // documents that it is not the one to answer it (it seeds on
1350 // sign changes, and a pair that never separates has none), so
1351 // what it traces over a coincident pair is noise wearing a
1352 // section's name, and it costs seconds to produce.
1353 let met = if ogeom_intersect::surface_surface(&fa.surface, &fb.surface, tol).is_err()
1354 && surfaces_coincide(&fa.surface, &fb.surface, options.tolerance, tol)
1355 {
1356 SurfaceIntersection::Same
1357 } else {
1358 intersect_surfaces(&fa.surface, &fb.surface, options, tol)?
1359 };
1360 match met {
1361 SurfaceIntersection::Apart => {}
1362 SurfaceIntersection::Same => {
1363 // Coincident surfaces offer no section curve; what splits
1364 // each face is the *other* face's boundary. Exact pcurve
1365 // projection carries an edge into the other chart, and
1366 // planes always have one; a curved same-domain pair whose
1367 // edges do not project in closed form is still refused.
1368 same_pairs.push((ia, ib));
1369 for (owner_from_a, owner, target_from_a, target, target_face) in
1370 [(false, fb, true, fa, ia), (true, fa, false, fb, ib)]
1371 {
1372 let _ = owner_from_a;
1373 for e in &owner.edges {
1374 let (pcurve, prange) = match ogeom_intersect::exact_pcurve_of(
1375 &e.curve,
1376 &target.surface,
1377 tol,
1378 ) {
1379 Some(exact) => (exact, e.crange),
1380 // A fitted edge has no closed-form projection,
1381 // but when the two faces sit on the
1382 // *identical chart*, which is exactly the
1383 // situation `Same` names for the analytics,
1384 // the owner's own stored pcurve already is
1385 // the projection, attached at construction,
1386 // and it travels with its own window the way
1387 // every stored pcurve does. A chart that
1388 // merely coincides as a point set is still
1389 // refused.
1390 None if same_chart(&owner.surface, &target.surface, tol) => {
1391 (e.pcurve.clone(), e.prange)
1392 }
1393 // Two patches that coincide as point sets
1394 // without being one chart (a blend's leg
1395 // on a spline host continued past the
1396 // face, against the face's own patch) get
1397 // the edge fitted by projection into the
1398 // target's chart, same-parameter with the
1399 // edge, the way every reader derives a
1400 // pcurve it was not given.
1401 None => {
1402 let Some(pcurve) = projected_into_shared_chart(
1403 &e.curve,
1404 e.crange,
1405 &owner.surface,
1406 &target.surface,
1407 tol,
1408 )?
1409 else {
1410 // Wholly outside the target's window
1411 // is wholly outside the target: a
1412 // blend's run-out past the face it
1413 // melts with splits nothing there.
1414 continue;
1415 };
1416 if *DEBUG_WIRE {
1417 eprintln!(
1418 "SAME owner {} face {} (from_a {owner_from_a}) edge {} of kind {:?}: {:?} .. {:?} against target face {target_face}",
1419 if owner_from_a { "a" } else { "b" },
1420 if owner_from_a { ia } else { ib },
1421 e.node.index(),
1422 core::mem::discriminant(&e.curve),
1423 e.curve.point_at(e.crange.0, tol).ok(),
1424 e.curve.point_at(e.crange.1, tol).ok()
1425 );
1426 }
1427 (pcurve, e.crange)
1428 }
1429 };
1430 contacts.push(ContactRec {
1431 curve: e.curve.clone(),
1432 crange: e.crange,
1433 pcurve,
1434 prange,
1435 node: e.node,
1436 tolerance: e.tolerance,
1437 target_from_a,
1438 target_face,
1439 });
1440 }
1441 }
1442 }
1443 // A touch at a point bounds nothing: no curve to split a
1444 // face along, no side that is inside on one hand and
1445 // outside on the other. It contributes to the arrangement
1446 // exactly what a tangential contact curve does, which is
1447 // nothing, and the result carries the touch as the
1448 // non-manifold contact it is.
1449 SurfaceIntersection::Touching(_) => {}
1450 SurfaceIntersection::Along(curves) => {
1451 for sc in curves {
1452 // A tangential curve is contact, not crossing: the
1453 // two faces meet along it and neither passes
1454 // through the other, so it is set aside from the
1455 // arrangement entirely and kept for the consumers
1456 // that draw contact rather than classify by it.
1457 if sc.tangential {
1458 if let (Some(pa), Some(pb)) = (sc.on_a, sc.on_b) {
1459 tangents.push(TangentRec {
1460 curve: sc.curve,
1461 pc_a: pa,
1462 pc_b: pb,
1463 face_a: ia,
1464 face_b: ib,
1465 });
1466 }
1467 continue;
1468 }
1469 // A section can be no longer than a turn round the
1470 // faces it cuts: a marched trace that wandered off
1471 // beside a chart's pole came back twenty-five times
1472 // the circle it stood for, faithfully fitted. That
1473 // is no section, and is refused by name.
1474 if !sc.exact {
1475 use ogeom_geom::Curve3d as _;
1476 let (lo, hi) = sc.curve.domain();
1477 let mut length = 0.0_f64;
1478 let mut last: Option<Point> = None;
1479 for k in 0..=64 {
1480 let t = if k == 64 {
1481 hi
1482 } else {
1483 lo + (hi - lo) * f64::from(k) / 64.0
1484 };
1485 let p = sc.curve.point_at(t, tol)?;
1486 if let Some(q) = last {
1487 length += q.distance(p);
1488 }
1489 last = Some(p);
1490 }
1491 let turn = 4.0 * fa.bound.diagonal().max(fb.bound.diagonal());
1492 if *DEBUG_WIRE {
1493 eprintln!(
1494 "SECTION CHECK faces {ia}/{ib} exact {} closed {} length {length:.4} turn {turn:.4} tol {:.2e}",
1495 sc.exact, sc.closed, sc.tolerance
1496 );
1497 }
1498 if length > turn {
1499 ogeom_bail!(
1500 NotDone,
1501 "a marched section of length {length} runs beyond a turn \
1502 round the faces it cuts ({turn}); its trace wandered \
1503 beside a chart's pole; see docs/PARITY.md, bool.booleans"
1504 );
1505 }
1506 }
1507 match (sc.on_a.clone(), sc.on_b.clone()) {
1508 (Some(pa), Some(pb)) => sections.push(SectionRec {
1509 curve: sc.curve,
1510 pc_a: pa,
1511 pc_b: pb,
1512 face_a: ia,
1513 face_b: ib,
1514 closed: sc.closed,
1515 tolerance: sc.tolerance,
1516 }),
1517 _ => {
1518 // A section running through a chart
1519 // degeneracy (a plane cutting a ball on its
1520 // own axis meets it at both poles) has no
1521 // single chart image, because the longitude
1522 // jumps half a turn there. Each piece
1523 // *between* the poles does have one, and it
1524 // is exact. Split first; march only if that
1525 // fails.
1526 if let Some(split) = split_at_degeneracies(&sc.curve, fa, fb, tol)?
1527 {
1528 for (curve, pa, pb) in split {
1529 sections.push(SectionRec {
1530 curve,
1531 pc_a: pa,
1532 pc_b: pb,
1533 face_a: ia,
1534 face_b: ib,
1535 closed: false,
1536 tolerance: 0.0,
1537 });
1538 }
1539 continue;
1540 }
1541 // An exact curve whose projection has no
1542 // closed form keeps its exactness and has
1543 // its chart image fitted: the curve's own
1544 // points inverted on the surface, read in
1545 // the chart, fitted at the curve's own
1546 // parameters: a plane's circle passing
1547 // beside a sphere chart's pole, whose image
1548 // swings fast but is a curve all the same.
1549 // Marching the pair, which follows, wandered
1550 // beside the pole in both ways there are.
1551 let image = |surface: &SurfaceGeometry,
1552 have: Option<&PlanarCurve>|
1553 -> OgeomResult<Option<(PlanarCurve, f64)>> {
1554 if let Some(pc) = have {
1555 return Ok(Some((pc.clone(), 0.0)));
1556 }
1557 fitted_image(&sc.curve, surface, options.tolerance, tol)
1558 };
1559 if let (Some((pa, ea)), Some((pb, eb))) = (
1560 image(&fa.surface, sc.on_a.as_ref())?,
1561 image(&fb.surface, sc.on_b.as_ref())?,
1562 ) {
1563 sections.push(SectionRec {
1564 curve: sc.curve,
1565 pc_a: pa,
1566 pc_b: pb,
1567 face_a: ia,
1568 face_b: ib,
1569 closed: sc.closed,
1570 tolerance: sc.tolerance.max(ea).max(eb),
1571 });
1572 continue;
1573 }
1574 // No image fits: march the pair instead, so
1575 // curve and pcurves are fitted *together*.
1576 if *DEBUG_WIRE {
1577 eprintln!(
1578 "MARCH PAIR faces {ia}/{ib} a {:?} b {:?} admitted {admitted}",
1579 core::mem::discriminant(&fa.surface),
1580 core::mem::discriminant(&fb.surface)
1581 );
1582 }
1583 let shared = if admitted {
1584 fa.bound.intersection(&fb.bound)
1585 } else {
1586 // Audit only: disjoint bounds have no
1587 // window, and an empty window would mask
1588 // the very miss being hunted.
1589 fa.bound.union(&fb.bound)
1590 };
1591 for fitted in march_pair(
1592 &windowed_to(&fa.surface, &shared),
1593 &windowed_to(&fb.surface, &shared),
1594 &options,
1595 tol,
1596 )? {
1597 // The same two refusals the intersector's
1598 // own marched sections meet: a fit a
1599 // thousand chords off its trace, and a
1600 // trace longer than a turn round the
1601 // faces. A plane's circle passing beside
1602 // a sphere chart's pole marched both ways
1603 // here (a section six tenths of a
1604 // millimetre off, a section twenty-five
1605 // laps long), and stated as data they
1606 // welded the tool into a point.
1607 let budget =
1608 (options.marching.chord * 1e3).max(options.tolerance * 1e3);
1609 if fitted.fit_error > budget {
1610 ogeom_bail!(
1611 NotDone,
1612 "a marched section's fit misses its trace by {} \
1613 against a chord of {}; a branch passing beside a \
1614 chart's pole fits nothing yet; see docs/PARITY.md, \
1615 boolean.general",
1616 fitted.fit_error,
1617 options.marching.chord
1618 );
1619 }
1620 let curve: Curve = fitted.curve.into();
1621 let length = {
1622 use ogeom_geom::Curve3d as _;
1623 let (lo, hi) = curve.domain();
1624 let mut length = 0.0_f64;
1625 let mut last: Option<Point> = None;
1626 for k in 0..=64 {
1627 let t = if k == 64 {
1628 hi
1629 } else {
1630 lo + (hi - lo) * f64::from(k) / 64.0
1631 };
1632 let p = curve.point_at(t, tol)?;
1633 if let Some(q) = last {
1634 length += q.distance(p);
1635 }
1636 last = Some(p);
1637 }
1638 length
1639 };
1640 let turn = 4.0 * fa.bound.diagonal().max(fb.bound.diagonal());
1641 if length > turn {
1642 ogeom_bail!(
1643 NotDone,
1644 "a marched section of length {length} runs beyond a \
1645 turn round the faces it cuts ({turn}); its trace \
1646 wandered beside a chart's pole; see docs/PARITY.md, \
1647 boolean.general"
1648 );
1649 }
1650 sections.push(SectionRec {
1651 closed: curve.is_closed(tol),
1652 tolerance: options.marching.chord + fitted.fit_error,
1653 curve,
1654 pc_a: fitted.on_a.into(),
1655 pc_b: fitted.on_b.into(),
1656 face_a: ia,
1657 face_b: ib,
1658 });
1659 }
1660 break;
1661 }
1662 }
1663 }
1664 }
1665 }
1666 }
1667 }
1668
1669 // Boundary polylines per face, for the trim tests.
1670 let outline = |face: &GFace| -> OgeomResult<Vec<Vec<Point2>>> {
1671 let mut lines = Vec::new();
1672 for e in &face.edges {
1673 lines.push(pcurve_polyline(
1674 &e.pcurve,
1675 e.prange,
1676 e.crange,
1677 e.crange,
1678 &face.surface,
1679 tol,
1680 )?);
1681 if let Some((other, orange)) = &e.other_side {
1682 lines.push(pcurve_polyline(
1683 other,
1684 *orange,
1685 e.crange,
1686 e.crange,
1687 &face.surface,
1688 tol,
1689 )?);
1690 }
1691 }
1692 weld_outline_ends(&mut lines, outline_snap(face, tol));
1693 Ok(lines)
1694 };
1695 let mut outlines_a = Vec::new();
1696 for f in &ga.faces {
1697 outlines_a.push(outline(f)?);
1698 }
1699 let mut outlines_b = Vec::new();
1700 for f in &gb.faces {
1701 outlines_b.push(outline(f)?);
1702 }
1703
1704 // Crossings of each section with the boundary edges of both its faces,
1705 // and with every other section sharing a face.
1706 let mut paves: std::collections::HashMap<EdgeKey, Vec<Pave>> = std::collections::HashMap::new();
1707 let mut pieces: Vec<SectionPiece> = Vec::new();
1708 // Each section's paving depends only on the sections and the two
1709 // gathered solids, all read-only here, and writes nothing the next
1710 // section reads. So the measuring runs in parallel and the accumulating
1711 // runs afterwards in section order: the same split `tessellate` uses,
1712 // and the same reason: nothing about scheduling can reach the answer.
1713 type SectionWork = (Vec<(EdgeKey, Pave)>, Vec<SectionPiece>, Vec<Junction>);
1714 let mut hug_junctions: Vec<Junction> = Vec::new();
1715 let paved: Vec<OgeomResult<SectionWork>> = ogeom_core::parallel::map_ordered(
1716 §ions,
1717 |si, section: &SectionRec| {
1718 ogeom_core::progress::checkpoint()?;
1719 let mut paves: Vec<(EdgeKey, Pave)> = Vec::new();
1720 let mut pieces: Vec<SectionPiece> = Vec::new();
1721 let mut junctions: Vec<Junction> = Vec::new();
1722 // A fitted section meets an edge within its own budget, not within
1723 // rounding.
1724 let reach = tol.confusion().max(section.tolerance * 2.0);
1725 let cc = CurveCurveOptions {
1726 gap: reach.max(CurveCurveOptions::default().gap),
1727 ..CurveCurveOptions::default()
1728 };
1729 let domain = section.curve.domain();
1730 let mut trim_ts: Vec<f64> = Vec::new();
1731 // Crossings with boundary edges: side, edge, parameter on the
1732 // edge, parameter on the section, and how honestly the stop sits.
1733 let mut hits: Vec<(usize, EdgeKey, f64, f64, f64)> = Vec::new();
1734 // Spans of the section running *along* a boundary edge. The split
1735 // such a span would make already exists as boundary (stacked boxes'
1736 // perpendicular side planes meet exactly at the boxes' own edges),
1737 // so the span is excluded from that face's strands rather than
1738 // refused or duplicated.
1739 //
1740 // Which face, though, is the whole point. A plane through a bore's
1741 // axis meets the wall along two rulings, and one of them is the
1742 // wall's own seam: on the wall that ruling is boundary already, and
1743 // on the plane it is the curve that separates the two halves the
1744 // section leaves. Dropped from both, the plane keeps one region where
1745 // it has two, and the result does not close. So the exclusion is
1746 // recorded per face.
1747 let mut along: [Vec<(f64, f64, usize)>; 2] = [Vec::new(), Vec::new()];
1748 for (side, own) in [
1749 (0_usize, &ga.faces[section.face_a]),
1750 (1, &gb.faces[section.face_b]),
1751 ] {
1752 // The other face the section must also lie in: a crossing
1753 // with this edge outside it stops nothing that is kept, and a
1754 // face bounded by thousands of edges would try every one.
1755 let across = if side == 0 {
1756 &gb.faces[section.face_b]
1757 } else {
1758 &ga.faces[section.face_a]
1759 };
1760 for (ei, e) in own.edges.iter().enumerate() {
1761 if !admit_all && !e.bound.expanded(reach).intersects(&across.bound) {
1762 continue;
1763 }
1764 // On a plane, an edge within its own radius of the face
1765 // (the boundary of a merged group of near-coplanar facets)
1766 // stands off the plane the section lies in by as much, and
1767 // meets the section only that near; it departs from the
1768 // face no other way, so a near miss there is the crossing.
1769 // A curved face's fitted rail can pass that near a section
1770 // without crossing it, and keeps the section's reach.
1771 let edge_reach = if matches!(own.surface, SurfaceGeometry::Plane(_)) {
1772 reach + e.tolerance
1773 } else {
1774 reach
1775 };
1776 let cc = CurveCurveOptions {
1777 gap: cc.gap.max(edge_reach),
1778 ..cc
1779 };
1780 let found = intersect_curves(§ion.curve, &e.curve, cc, tol)?;
1781 for crossing in &found.crossings {
1782 if crossing.gap > edge_reach {
1783 continue;
1784 }
1785 let mut on_b = onto_range(crossing.on_b, &e.curve, e.crange, tol);
1786 let mut honesty = honest(section.tolerance, tol);
1787 // A crossing at a boundary edge's own end *is* that end's
1788 // vertex, exactly. The stop the section keeps must be the
1789 // vertex's parameter on the section: the meet of two
1790 // curves a fit tolerance apart lands a couple of microns
1791 // off, the boundary side keeps its exact vertex, and the
1792 // rebuilt wire gapes by the difference.
1793 let mut on_a = crossing.on_a;
1794 // The window is the fit-slop scale, not the section's
1795 // own: the boundary curve may be a fitted intersection
1796 // from an earlier boolean carrying a couple of microns
1797 // of wobble, and a stop that misses the vertex by that
1798 // much gapes the rebuilt wire by the same. A fitted
1799 // section's stop misses by its own honesty once more:
1800 // the trace is off the true crossing by that, and
1801 // the crossing found against the edge by that again.
1802 let weld =
1803 (reach + honest(section.tolerance, tol)).max(tol.confusion() * 1e2);
1804 let mut at_end: Option<(f64, Point)> = None;
1805 for end in [e.crange.0, e.crange.1] {
1806 let vertex = e.curve.point_at(end, tol)?;
1807 if vertex.distance(crossing.point) <= weld + crossing.reach {
1808 at_end = Some((end, vertex));
1809 let snapped =
1810 ogeom_algo::project_on_curve(§ion.curve, vertex, 64, tol)?;
1811 if snapped.distance <= weld + crossing.reach {
1812 on_a = snapped.parameter;
1813 // And the edge's own parameter is the
1814 // end's: two rim circles through one
1815 // block corner meet at a shallow angle
1816 // there, the crossing lands a few
1817 // hundredths of a millimetre along the
1818 // rim from the corner, and paved there
1819 // it split the rim into a sliver.
1820 on_b = end;
1821 }
1822 break;
1823 }
1824 }
1825 // A touch at the section's own end *is* the end. A
1826 // band's section through a wall meets the wall's drum
1827 // edge tangentially where the band is tangent to the
1828 // drum, and the touch of two fitted curves at a
1829 // shallow angle wanders along them by far more than
1830 // their honesty; but the section stops there because
1831 // it leaves its own face, and that stop is the
1832 // junction: the crossing takes the end's parameter
1833 // and the edge splits under the end itself.
1834 let touch = crossing.reach > 0.0
1835 || tangential(
1836 §ion.curve,
1837 crossing.on_a,
1838 &e.curve,
1839 crossing.on_b,
1840 tol,
1841 )?;
1842 if touch {
1843 let half = (domain.1 - domain.0) * 0.5;
1844 for end in [domain.0, domain.1] {
1845 if (crossing.on_a - end).abs() > half {
1846 continue;
1847 }
1848 let tip = section
1849 .curve
1850 .point_at(at_param(end, domain, section.closed), tol)?;
1851 if crossing.reach > 0.0
1852 && tip.distance(crossing.point) > crossing.reach + weld
1853 {
1854 continue;
1855 }
1856 // The tip stands where the marcher left the
1857 // other face (on a fitted rail, to the rail's
1858 // own honesty), so the edge is asked at the
1859 // hug width, not the section's.
1860 let foot = ogeom_algo::project_on_curve(&e.curve, tip, 64, tol)?;
1861 let width =
1862 (crossing.reach + honest(weld, tol)).max(tol.confusion() * 1e3);
1863 if *DEBUG_WIRE {
1864 eprintln!(
1865 "PAVE s{si}: touch on edge {} at {:.6}: tip {:.3e} off the edge, width {width:.2e}",
1866 e.node.index(),
1867 crossing.on_a,
1868 foot.distance
1869 );
1870 }
1871 if foot.distance <= width {
1872 on_a = end;
1873 on_b = onto_range(foot.parameter, &e.curve, e.crange, tol);
1874 // A touch at the edge's own end vertex
1875 // is that vertex: a section tangent to
1876 // the edge there runs along it for a
1877 // stretch either side, stops somewhere
1878 // on that stretch, and split where it
1879 // stopped the edge keeps a sliver the
1880 // face across the vertex never has.
1881 if let Some((end_b, vertex)) = at_end
1882 && tip.distance(vertex) <= width.min(tol.confusion() * 1e5)
1883 {
1884 on_b = end_b;
1885 let gap = tip.distance(vertex);
1886 if gap > tol.confusion() * 1e2 {
1887 junctions.push(Junction {
1888 at: vertex,
1889 reach: gap + tol.confusion() * 1e2,
1890 onto_vertex: true,
1891 });
1892 }
1893 }
1894 // Along the edge the touch is known only
1895 // as far as the two curves stay together:
1896 // a rail grazing a bore is met there by
1897 // the sections on the faces either side
1898 // of it a hundredth of a millimetre
1899 // apart, and those are one junction.
1900 honesty = honesty.max(foot.distance).max(crossing.reach);
1901 }
1902 break;
1903 }
1904 }
1905 if *DEBUG_WIRE {
1906 eprintln!(
1907 "PAVE s{si}: side {side} edge {} crossing at {on_a:.6} (edge {on_b:.6}) gap {:.2e} reach {:.2e} honesty {honesty:.2e} at {:?}",
1908 e.node.index(),
1909 crossing.gap,
1910 crossing.reach,
1911 crossing.point
1912 );
1913 }
1914 hits.push((side, e.node, on_b, on_a, honesty));
1915 }
1916 for overlap in &found.overlaps {
1917 // The curves overlap; what is *boundary* is the stretch
1918 // the edge actually covers. A sphere's seam and the far
1919 // half of the same great circle lie on one curve, and
1920 // reading the whole curve as boundary makes the meridian
1921 // opposite the seam disappear, which is the octant's own
1922 // edge, on a ball cut at its corner.
1923 let Some((lo, hi)) = overlap_within(overlap, e.crange, &e.curve, tol)
1924 else {
1925 continue;
1926 };
1927 if *DEBUG_WIRE {
1928 eprintln!("PAVE s{si}: side {side} overlap ({lo:.6}, {hi:.6})");
1929 }
1930 // The edge splits where the shared stretch ends, as
1931 // the section does: the stretch itself is the edge's
1932 // to carry, and the section's next piece must meet
1933 // the edge at a vertex the edge actually has. A band
1934 // meeting a wall tangentially to the wall's own top
1935 // edge hugs it for a few microns from their corner;
1936 // without the split at the hug's far end the section
1937 // dangles there and is pruned, and the wall never
1938 // splits.
1939 // The section leaves the edge at the hug's end by
1940 // what the hug allowed, which no single strand's
1941 // honesty covers: that junction owns the gap.
1942 for t in [lo, hi] {
1943 let at = section
1944 .curve
1945 .point_at(at_param(t, domain, section.closed), tol)?;
1946 let foot = ogeom_algo::project_on_curve(&e.curve, at, 64, tol)?;
1947 if foot.distance <= reach.max(tol.confusion() * 1e3) {
1948 let on_b = onto_range(foot.parameter, &e.curve, e.crange, tol);
1949 hits.push((
1950 side,
1951 e.node,
1952 on_b,
1953 t,
1954 honest(section.tolerance, tol).max(foot.distance),
1955 ));
1956 if foot.distance > tol.confusion() * 1e2 {
1957 junctions.push(Junction {
1958 at: foot.point,
1959 reach: foot.distance + tol.confusion() * 1e2,
1960 onto_vertex: false,
1961 });
1962 }
1963 }
1964 }
1965 trim_ts.push(lo);
1966 trim_ts.push(hi);
1967 along[side].push((lo, hi, ei));
1968 }
1969 }
1970 }
1971 // Inside a stretch the section runs along one of a face's edges,
1972 // that face's other edges cannot genuinely cross it: a boundary
1973 // is a simple loop, and they meet the hugged edge only at its
1974 // ends, which the stretch's own ends already stop at. What the
1975 // sampler reports there is the tangential dust of a leg touching
1976 // the arc it ends on: thirty near-crossings inside a micron,
1977 // which read as stops would shatter the section and pave the
1978 // leg at each.
1979 hits.retain(|(side, _, _, on_a, _)| {
1980 !along[*side].iter().any(|(lo, hi, _)| {
1981 *on_a > lo + tol.parametric() && *on_a < hi - tol.parametric()
1982 })
1983 });
1984 let edge_hits: Vec<(EdgeKey, f64, f64, f64)> = hits
1985 .iter()
1986 .map(|(_, node, on_b, on_a, honesty)| (*node, *on_b, *on_a, *honesty))
1987 .collect();
1988 trim_ts.extend(hits.iter().map(|(_, _, _, on_a, _)| *on_a));
1989 let mut cross_ts: Vec<f64> = Vec::new();
1990 for (sj, other) in sections.iter().enumerate() {
1991 if sj == si {
1992 continue;
1993 }
1994 if other.face_a != section.face_a && other.face_b != section.face_b {
1995 continue;
1996 }
1997 let both = reach.max(tol.confusion().max(other.tolerance * 2.0));
1998 // Where two sections share one face, a crossing matters only
1999 // inside the faces they do not share, since a section is kept
2000 // only where it lies in both its faces: one wall met by a
2001 // thousand facets carries a thousand sections, and only those
2002 // whose facets' bounds meet can cross where it counts. The
2003 // audit's unfiltered pass tries every pair still.
2004 if !admit_all {
2005 let apart = |x: &ogeom_math::Aabb, y: &ogeom_math::Aabb| {
2006 !x.expanded(both).intersects(&y.expanded(both))
2007 };
2008 let unshared_a = other.face_a != section.face_a
2009 && apart(
2010 &ga.faces[section.face_a].bound,
2011 &ga.faces[other.face_a].bound,
2012 );
2013 let unshared_b = other.face_b != section.face_b
2014 && apart(
2015 &gb.faces[section.face_b].bound,
2016 &gb.faces[other.face_b].bound,
2017 );
2018 if unshared_a || unshared_b {
2019 continue;
2020 }
2021 }
2022 let cc2 = CurveCurveOptions {
2023 gap: both.max(CurveCurveOptions::default().gap),
2024 ..CurveCurveOptions::default()
2025 };
2026 let found = intersect_curves(§ion.curve, &other.curve, cc2, tol)?;
2027 for crossing in &found.crossings {
2028 if crossing.gap > both {
2029 continue;
2030 }
2031 let mut at = crossing.on_a;
2032 // A touch at this section's end is the end, as against an
2033 // edge: two sections through one wall from a band and
2034 // the leg it is tangent to touch where the band's ends.
2035 let touch = crossing.reach > 0.0
2036 || tangential(
2037 §ion.curve,
2038 crossing.on_a,
2039 &other.curve,
2040 crossing.on_b,
2041 tol,
2042 )?;
2043 if touch {
2044 let half = (domain.1 - domain.0) * 0.5;
2045 for end in [domain.0, domain.1] {
2046 if (crossing.on_a - end).abs() > half {
2047 continue;
2048 }
2049 let tip = section
2050 .curve
2051 .point_at(at_param(end, domain, section.closed), tol)?;
2052 let foot = ogeom_algo::project_on_curve(&other.curve, tip, 64, tol)?;
2053 let width =
2054 (crossing.reach + honest(both, tol)).max(tol.confusion() * 1e3);
2055 if foot.distance <= width {
2056 at = end;
2057 }
2058 break;
2059 }
2060 }
2061 // Two sections through one face that lie on two different
2062 // faces of the other solid cross only on the edge those
2063 // two share, where each already stops. Meeting there
2064 // tangentially (a wall through a plane and the fillet
2065 // tangent to it), their own crossing is ill-conditioned
2066 // and lands off the edge, as far off as the two stay
2067 // within their doubt of each other; the edge's stop is
2068 // the one where they cannot be told apart up to it.
2069 let shared = |edges: &[BoundaryEdge], node: EdgeKey| {
2070 edges.iter().any(|e| e.node == node)
2071 };
2072 let apart_on = if other.face_a == section.face_a {
2073 Some(&gb.faces[other.face_b].edges)
2074 } else if other.face_b == section.face_b {
2075 Some(&ga.faces[other.face_a].edges)
2076 } else {
2077 None
2078 };
2079 if let Some(edges) = apart_on {
2080 let close = both.max(tol.confusion() * 1e2);
2081 let mut best: Option<(f64, f64)> = None;
2082 for (node, _, on_a, _) in &edge_hits {
2083 if !shared(edges, *node)
2084 || best.is_some_and(|(bd, _)| (on_a - at).abs() >= bd)
2085 {
2086 continue;
2087 }
2088 let mut together = true;
2089 for k in 1..8 {
2090 let t = (on_a - at).mul_add(f64::from(k) / 8.0, at);
2091 let p = section
2092 .curve
2093 .point_at(at_param(t, domain, section.closed), tol)?;
2094 if ogeom_algo::project_on_curve(&other.curve, p, 16, tol)?.distance
2095 > close
2096 {
2097 together = false;
2098 break;
2099 }
2100 }
2101 if together {
2102 best = Some(((on_a - at).abs(), *on_a));
2103 }
2104 }
2105 if let Some((_, on_a)) = best {
2106 at = on_a;
2107 }
2108 }
2109 if *DEBUG_WIRE {
2110 eprintln!(
2111 "PAVE s{si}: cross s{sj} at {at:.6} gap {:.2e}",
2112 crossing.gap
2113 );
2114 }
2115 cross_ts.push(at);
2116 }
2117 }
2118
2119 // Candidate intervals between trim crossings, kept where the middle
2120 // sits inside both faces' trims.
2121 trim_ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
2122 trim_ts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
2123 // Stops the section cannot tell apart are one stop: two
2124 // crossings a few nanometres apart along it would make an
2125 // interval of dust that one face keeps and the other drops.
2126 {
2127 let floor = (honest(section.tolerance, tol) * 3.0).max(tol.confusion() * 10.0);
2128 let mut kept: Vec<f64> = Vec::with_capacity(trim_ts.len());
2129 let mut held: Option<Point> = None;
2130 for t in &trim_ts {
2131 let at = section
2132 .curve
2133 .point_at(at_param(*t, domain, section.closed), tol)?;
2134 if held.is_none_or(|h: Point| h.distance(at) > floor) {
2135 kept.push(*t);
2136 held = Some(at);
2137 }
2138 }
2139 trim_ts = kept;
2140 }
2141 let mut candidates: Vec<(f64, f64)> = Vec::new();
2142 if section.closed {
2143 let period = domain.1 - domain.0;
2144 if trim_ts.is_empty() {
2145 candidates.push(domain);
2146 } else {
2147 for i in 0..trim_ts.len() {
2148 let lo = trim_ts[i];
2149 let hi = if i + 1 < trim_ts.len() {
2150 trim_ts[i + 1]
2151 } else {
2152 trim_ts[0] + period
2153 };
2154 candidates.push((lo, hi));
2155 }
2156 }
2157 } else {
2158 let mut stops = vec![domain.0];
2159 stops.extend(
2160 trim_ts
2161 .iter()
2162 .copied()
2163 .filter(|t| *t > domain.0 && *t < domain.1),
2164 );
2165 stops.push(domain.1);
2166 for pair in stops.windows(2) {
2167 candidates.push((pair[0], pair[1]));
2168 }
2169 }
2170
2171 // Whether the section at `t` lies inside each face's trim.
2172 let inside_each = |t: f64| -> OgeomResult<[bool; 2]> {
2173 let tf = if section.closed { fold(t, domain) } else { t };
2174 let la: Vec<&[Point2]> = outlines_a[section.face_a]
2175 .iter()
2176 .map(Vec::as_slice)
2177 .collect();
2178 let lb: Vec<&[Point2]> = outlines_b[section.face_b]
2179 .iter()
2180 .map(Vec::as_slice)
2181 .collect();
2182 let ua = fold_inside(
2183 section.pc_a.point_at(tf, tol)?,
2184 &ga.faces[section.face_a].surface,
2185 &la,
2186 );
2187 let ub = fold_inside(
2188 section.pc_b.point_at(tf, tol)?,
2189 &gb.faces[section.face_b].surface,
2190 &lb,
2191 );
2192 Ok([inside_many(&la, ua), inside_many(&lb, ub)])
2193 };
2194
2195 for (lo, hi) in candidates {
2196 if hi - lo <= tol.parametric() {
2197 continue;
2198 }
2199 let mid = f64::midpoint(lo, hi);
2200 let mid_folded = if section.closed {
2201 fold(mid, domain)
2202 } else {
2203 mid
2204 };
2205 let held = inside_each(mid)?;
2206 if *DEBUG_WIRE {
2207 let tf = if section.closed {
2208 fold(mid, domain)
2209 } else {
2210 mid
2211 };
2212 eprintln!(
2213 "PAVE s{si}: candidate ({lo:.6}, {hi:.6}) inside {held:?} at a {:?} b {:?}",
2214 section
2215 .pc_a
2216 .point_at(tf, tol)
2217 .ok()
2218 .map(|q| fold_point_into_chart(q, &ga.faces[section.face_a].surface)),
2219 section
2220 .pc_b
2221 .point_at(tf, tol)
2222 .ok()
2223 .map(|q| fold_point_into_chart(q, &gb.faces[section.face_b].surface))
2224 );
2225 }
2226 // A section that runs along a boundary edge of a face splits
2227 // nothing *there*: the split already exists as boundary. The
2228 // analytic overlap detection above catches the same-support
2229 // cases; this catches the rest (a fitted section tracing a
2230 // boundary curve, a surface meeting another exactly at its own
2231 // trim) by measurement rather than by recognising supports.
2232 // The hug is asked before the trim: a section on a face's own
2233 // edge reads inside or outside that face by a hair, and the
2234 // corner tool's block face meets a band exactly along the arc
2235 // that bounds it. On the hugging face it is boundary already;
2236 // what matters is whether the *other* face holds it.
2237 let mut hugs = [false; 2];
2238 // The edges each side hugs (a rim already in pieces is
2239 // several): they must split wherever the section does, and
2240 // the section wherever they end, or the two faces walk
2241 // different subdivisions of one curve.
2242 let mut hugged: [Vec<usize>; 2] = [Vec::new(), Vec::new()];
2243 for (side, own, side_from_a, side_face) in [
2244 (0_usize, &ga.faces[section.face_a], true, section.face_a),
2245 (1, &gb.faces[section.face_b], false, section.face_b),
2246 ] {
2247 if let Some((_, _, ei)) = along[side]
2248 .iter()
2249 .find(|(alo, ahi, _)| mid_folded >= *alo && mid_folded <= *ahi)
2250 {
2251 hugs[side] = true;
2252 hugged[side].push(*ei);
2253 continue;
2254 }
2255 let mut all_near = true;
2256 let mut votes: Vec<usize> = vec![0; own.edges.len()];
2257 let mut contact_votes: Vec<usize> = vec![0; contacts.len()];
2258 for i in 0..=4 {
2259 let t = lo + (hi - lo) * f64::from(i) / 4.0;
2260 let tf = if section.closed { fold(t, domain) } else { t };
2261 let at = section.curve.point_at(tf, tol)?;
2262 // Wider than the crossing filters on purpose for a
2263 // fitted section: a tangentially-traced curve wobbles
2264 // about the boundary it hugs by far more than a fit
2265 // budget, and a genuine section keeps a distance of
2266 // feature scale, not microns. An exact section keeps
2267 // to the edge's own honesty: a plane's ellipse across
2268 // a band touches the band's rail tangentially, and
2269 // the stretch within a tenth of a millimetre of the
2270 // rail is the section, not the rail; read as a hug
2271 // it left the cap's crescent unable to close.
2272 let floor = if section.tolerance > 0.0 {
2273 tol.confusion() * 1e3
2274 } else {
2275 tol.confusion() * 10.0
2276 };
2277 let mut near = false;
2278 for (ei, e) in own.edges.iter().enumerate() {
2279 let width = reach.max(floor).max(e.tolerance * 2.0);
2280 let d = distance_to_edge_curve(&e.curve, e.crange, at, tol)?;
2281 if d <= width {
2282 if *DEBUG_WIRE {
2283 eprintln!(
2284 "PAVE s{si}: side {side} sample {i} hugs edge {} at {d:.2e} (width {width:.2e}) range {:?} point {at:?}",
2285 e.node.index(),
2286 e.crange
2287 );
2288 }
2289 // Every edge within reach is hugged, not the
2290 // first found: where three rims converge on
2291 // a pole corner all three come within a
2292 // micron of the section, and the one it
2293 // actually runs along must not lose its
2294 // vote to a neighbour listed before it.
2295 near = true;
2296 votes[ei] += 1;
2297 }
2298 }
2299 let width = reach.max(floor);
2300 // A contact edge is a strand on this face too (the other
2301 // solid's boundary, carried into a chart they share), so a
2302 // section tracing one would be the same curve twice, and
2303 // the arrangement cannot walk a line it meets from both
2304 // sides at once. Two boxes side by side put the low one's
2305 // lid exactly there.
2306 for (ci, c) in contacts.iter().enumerate() {
2307 if c.target_from_a != side_from_a || c.target_face != side_face {
2308 continue;
2309 }
2310 if distance_to_edge_curve(&c.curve, c.crange, at, tol)?
2311 <= width.max(c.tolerance * 2.0)
2312 {
2313 near = true;
2314 contact_votes[ci] += 1;
2315 }
2316 }
2317 if !near {
2318 all_near = false;
2319 break;
2320 }
2321 }
2322 // And along one of them all the way: a section winding
2323 // round a drum passes the drum's seam once a turn, and a
2324 // stretch spanning whole turns puts every sample on the
2325 // seam without running along it anywhere between.
2326 let one_line = votes.iter().chain(&contact_votes).any(|&v| v == 5);
2327 hugs[side] = all_near && one_line;
2328 if all_near {
2329 hugged[side].extend(
2330 votes
2331 .iter()
2332 .enumerate()
2333 .filter(|(_, c)| **c > 0)
2334 .map(|(ei, _)| ei),
2335 );
2336 }
2337 }
2338 if *DEBUG_WIRE {
2339 eprintln!("PAVE s{si}: candidate ({lo:.6}, {hi:.6}) hugs {hugs:?}");
2340 }
2341 // Inside both faces the piece is a section of both. Hugging
2342 // one face's own edge while that face's trim reads it out
2343 // (by a hair, on the edge), it is still the other face's
2344 // split, and is admitted there, unless that edge is already
2345 // carried onto the other face as a contact: then the split
2346 // is laid down once already, and a section would lay it
2347 // twice.
2348 let mut hug_key: Option<(EdgeKey, bool, usize)> = None;
2349 let admitted = if held[0] && held[1] {
2350 true
2351 } else if hugs[0] != hugs[1] {
2352 let hugging = usize::from(hugs[1]);
2353 let other = 1 - hugging;
2354 let (own, other_from_a, other_face) = if hugging == 0 {
2355 (&ga.faces[section.face_a], false, section.face_b)
2356 } else {
2357 (&gb.faces[section.face_b], true, section.face_a)
2358 };
2359 let carried = hugged[hugging].iter().any(|&ei| {
2360 contacts.iter().any(|c| {
2361 c.node == own.edges[ei].node
2362 && c.target_from_a == other_from_a
2363 && c.target_face == other_face
2364 })
2365 });
2366 if held[other] && !carried {
2367 hug_key = hugged[hugging]
2368 .first()
2369 .map(|&ei| (own.edges[ei].node, other_from_a, other_face));
2370 true
2371 } else {
2372 false
2373 }
2374 } else {
2375 false
2376 };
2377 if !admitted {
2378 continue;
2379 }
2380 if hugs[0] && hugs[1] {
2381 // Boundary on both sides: the split exists twice over and
2382 // adding it a third time would cancel what it copies.
2383 continue;
2384 }
2385 // Keep the paves that end a kept interval: those are where edges
2386 // genuinely split.
2387 for (node, on_edge, on_section, honesty) in &edge_hits {
2388 let s = *on_section;
2389 let near = |x: f64| {
2390 (s - x).abs() <= tol.parametric()
2391 || (section.closed
2392 && ((s + (domain.1 - domain.0)) - x).abs() <= tol.parametric())
2393 };
2394 if near(lo) || near(hi) {
2395 paves.push((
2396 *node,
2397 Pave {
2398 t: *on_edge,
2399 honesty: *honesty,
2400 },
2401 ));
2402 }
2403 }
2404 // Split at section/section crossings inside the kept interval,
2405 // so every face sees the same subdivision.
2406 let mut cuts = vec![lo];
2407 // A wrap interval also splits at the curve's own domain end, so
2408 // every piece lives within one period and evaluates in-domain
2409 // after a single fold.
2410 if section.closed
2411 && domain.1 > lo + tol.parametric()
2412 && domain.1 < hi - tol.parametric()
2413 {
2414 cuts.push(domain.1);
2415 }
2416 for &c in &cross_ts {
2417 let c2 = if section.closed && c < lo {
2418 c + (domain.1 - domain.0)
2419 } else {
2420 c
2421 };
2422 if c2 > lo + tol.parametric() && c2 < hi - tol.parametric() {
2423 cuts.push(c2);
2424 }
2425 }
2426 cuts.push(hi);
2427 // A hugged edge's own ends are cuts of the section too: the
2428 // edge may already be several pieces (a sphere's rim split
2429 // at its chart seam), and the section must walk the same
2430 // pieces, or the two faces never sew along it.
2431 for side in 0..2 {
2432 if !hugs[side] {
2433 continue;
2434 }
2435 let own = if side == 0 {
2436 &ga.faces[section.face_a]
2437 } else {
2438 &gb.faces[section.face_b]
2439 };
2440 for &ei in &hugged[side] {
2441 let e = &own.edges[ei];
2442 for end in [e.crange.0, e.crange.1] {
2443 let p = e.curve.point_at(end, tol)?;
2444 let foot = ogeom_algo::project_on_curve(§ion.curve, p, 64, tol)?;
2445 if *DEBUG_WIRE {
2446 eprintln!(
2447 "PAVE s{si}: side {side} hugged edge {} end {end:.6} lands at {:.6} off {:.2e} against ({lo:.6}, {hi:.6})",
2448 e.node.index(),
2449 foot.parameter,
2450 foot.distance
2451 );
2452 }
2453 if foot.distance > reach.max(tol.confusion() * 1e3) {
2454 continue;
2455 }
2456 let mut t = foot.parameter;
2457 if section.closed {
2458 let period = domain.1 - domain.0;
2459 while t < lo {
2460 t += period;
2461 }
2462 while t - period >= lo {
2463 t -= period;
2464 }
2465 }
2466 if t > lo + tol.parametric() && t < hi - tol.parametric() {
2467 if *DEBUG_WIRE {
2468 eprintln!(
2469 "PAVE s{si}: side {side} cut at {t:.6} where hugged edge {} ends",
2470 e.node.index()
2471 );
2472 }
2473 cuts.push(t);
2474 }
2475 }
2476 }
2477 }
2478 cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
2479 // Cuts the section cannot tell apart are one cut: two
2480 // crossings a few nanometres apart along it would leave a
2481 // piece of dust one face keeps and the other drops. The
2482 // interval's own ends stay.
2483 {
2484 let floor = (honest(section.tolerance, tol) * 3.0).max(tol.confusion() * 10.0);
2485 let mut kept: Vec<f64> = Vec::with_capacity(cuts.len());
2486 let mut held: Option<Point> = None;
2487 for (index, c) in cuts.iter().enumerate() {
2488 let at = section
2489 .curve
2490 .point_at(at_param(*c, domain, section.closed), tol)?;
2491 let last = index + 1 == cuts.len();
2492 if index == 0 || last || held.is_none_or(|h: Point| h.distance(at) > floor)
2493 {
2494 kept.push(*c);
2495 held = Some(at);
2496 }
2497 }
2498 cuts = kept;
2499 }
2500 // A hugged edge is boundary where this piece is section: it
2501 // must split at the piece's interior cuts too, or the face
2502 // that keeps the edge walks one long strand where its
2503 // neighbour walks three, and the sew pairs none of them.
2504 for side in 0..2 {
2505 if !hugs[side] {
2506 continue;
2507 }
2508 let own = if side == 0 {
2509 &ga.faces[section.face_a]
2510 } else {
2511 &gb.faces[section.face_b]
2512 };
2513 for &ei in &hugged[side] {
2514 let e = &own.edges[ei];
2515 // The piece's own ends as well as its interior cuts: a
2516 // stop against another face's edge is a split of the
2517 // hugged edge too, or the section's neighbour walks two
2518 // strands where the hugged edge's face walks one.
2519 for c in &cuts {
2520 let at = section
2521 .curve
2522 .point_at(at_param(*c, domain, section.closed), tol)?;
2523 let foot = ogeom_algo::project_on_curve(&e.curve, at, 64, tol)?;
2524 // A cut off the edge (past the stretch the
2525 // section hugs, on a neighbouring edge) is no
2526 // split of it; stated as one with its distance
2527 // for honesty it seeds a junction a feature wide.
2528 if foot.distance > reach.max(tol.confusion() * 1e3) {
2529 continue;
2530 }
2531 let on_e = onto_range(foot.parameter, &e.curve, e.crange, tol);
2532 if on_e <= e.crange.0.min(e.crange.1) + tol.parametric()
2533 || on_e >= e.crange.0.max(e.crange.1) - tol.parametric()
2534 {
2535 continue;
2536 }
2537 if *DEBUG_WIRE {
2538 eprintln!(
2539 "PAVE s{si}: side {side} hugged edge {} paved at {on_e:.6} for the cut at {c:.6}",
2540 e.node.index()
2541 );
2542 }
2543 paves.push((
2544 e.node,
2545 Pave {
2546 t: on_e,
2547 honesty: honest(section.tolerance, tol).max(foot.distance),
2548 },
2549 ));
2550 }
2551 }
2552 }
2553 for pair in cuts.windows(2) {
2554 let (lo2, hi2) = (pair[0], pair[1]);
2555 let from = section
2556 .curve
2557 .point_at(at_param(lo2, domain, section.closed), tol)?;
2558 let to = section
2559 .curve
2560 .point_at(at_param(hi2, domain, section.closed), tol)?;
2561 if from.distance(to) <= tol.confusion() {
2562 // A full loop: two arcs, so every strand has two
2563 // distinct endpoints.
2564 let mid = f64::midpoint(lo2, hi2);
2565 pieces.push(SectionPiece {
2566 section: si,
2567 range: (lo2, mid),
2568 hugs,
2569 hug_key,
2570 });
2571 pieces.push(SectionPiece {
2572 section: si,
2573 range: (mid, hi2),
2574 hugs,
2575 hug_key,
2576 });
2577 } else {
2578 pieces.push(SectionPiece {
2579 section: si,
2580 range: (lo2, hi2),
2581 hugs,
2582 hug_key,
2583 });
2584 }
2585 }
2586 }
2587 Ok((paves, pieces, junctions))
2588 },
2589 );
2590 for work in paved {
2591 let (found, made, hugged) = work?;
2592 hug_junctions.extend(hugged);
2593 for (node, at) in found {
2594 paves.entry(node).or_default().push(at);
2595 }
2596 pieces.extend(made);
2597 }
2598 // A piece running along a face's own edge and that edge are one curve,
2599 // split twice: the piece where its section's cuts fell, the edge where
2600 // every section that met it paved it, each worked out apart. A loop
2601 // hugging a rim is split at its middle, the rim wherever another
2602 // section crossed it; the two faces then walk different pieces of one
2603 // circle and never sew. Now that every pave is in, each such piece is
2604 // cut wherever its edge is split, and the edge split wherever the piece
2605 // ends, twice over so a split one piece adds reaches the others.
2606 for _ in 0..2 {
2607 let mut settled: Vec<SectionPiece> = Vec::with_capacity(pieces.len());
2608 for piece in pieces.drain(..) {
2609 if !(piece.hugs[0] || piece.hugs[1]) {
2610 settled.push(piece);
2611 continue;
2612 }
2613 let section = §ions[piece.section];
2614 let domain = section.curve.domain();
2615 let period = domain.1 - domain.0;
2616 let (lo, hi) = piece.range;
2617 let at = |t: f64| {
2618 section
2619 .curve
2620 .point_at(at_param(t, domain, section.closed), tol)
2621 };
2622 let width = (tol.confusion() * 1e3).max(section.tolerance * 3.0);
2623 let mid = at(f64::midpoint(lo, hi))?;
2624 let mut hugged: Vec<&BoundaryEdge> = Vec::new();
2625 for side in 0..2 {
2626 if !piece.hugs[side] {
2627 continue;
2628 }
2629 let face = if side == 0 {
2630 &ga.faces[section.face_a]
2631 } else {
2632 &gb.faces[section.face_b]
2633 };
2634 for e in &face.edges {
2635 if distance_to_edge_curve(&e.curve, e.crange, mid, tol)?
2636 <= width.max(e.tolerance * 2.0)
2637 {
2638 hugged.push(e);
2639 }
2640 }
2641 }
2642 let mut cuts: Vec<f64> = Vec::new();
2643 for e in &hugged {
2644 let mut on_edge: Vec<f64> = vec![e.crange.0, e.crange.1];
2645 if let Some(list) = paves.get(&e.node) {
2646 on_edge.extend(list.iter().map(|pave| pave.t));
2647 }
2648 for t in on_edge {
2649 let q = e.curve.point_at(t, tol)?;
2650 let foot = ogeom_algo::project_on_curve(§ion.curve, q, 64, tol)?;
2651 if foot.distance > width.max(e.tolerance * 2.0) {
2652 continue;
2653 }
2654 let mut f = foot.parameter;
2655 if section.closed && period > 0.0 {
2656 while f < lo {
2657 f += period;
2658 }
2659 while f - period >= lo {
2660 f -= period;
2661 }
2662 }
2663 if f > lo + tol.parametric() && f < hi - tol.parametric() {
2664 cuts.push(f);
2665 }
2666 }
2667 }
2668 cuts.push(lo);
2669 cuts.push(hi);
2670 cuts.sort_by(f64::total_cmp);
2671 cuts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
2672 // The edge split wherever the piece now ends.
2673 for e in &hugged {
2674 for &c in &cuts {
2675 let q = at(c)?;
2676 let foot = ogeom_algo::project_on_curve(&e.curve, q, 64, tol)?;
2677 if foot.distance > width.max(e.tolerance * 2.0) {
2678 continue;
2679 }
2680 let on_e = onto_range(foot.parameter, &e.curve, e.crange, tol);
2681 if on_e <= e.crange.0.min(e.crange.1) + tol.parametric()
2682 || on_e >= e.crange.0.max(e.crange.1) - tol.parametric()
2683 {
2684 continue;
2685 }
2686 let list = paves.entry(e.node).or_default();
2687 if list
2688 .iter()
2689 .all(|pave| (pave.t - on_e).abs() > tol.parametric())
2690 {
2691 list.push(Pave {
2692 t: on_e,
2693 honesty: honest(section.tolerance, tol).max(foot.distance),
2694 });
2695 }
2696 }
2697 }
2698 for pair in cuts.windows(2) {
2699 settled.push(SectionPiece {
2700 section: piece.section,
2701 range: (pair[0], pair[1]),
2702 hugs: piece.hugs,
2703 hug_key: piece.hug_key,
2704 });
2705 }
2706 }
2707 pieces = settled;
2708 }
2709 // Two sections hugging onto one face along one line lay the same split
2710 // down twice (a top face's trace and its band's, both along the rail
2711 // they share, which need not be one edge node once a shell has been
2712 // built), and a line met from both sides at once breaks the
2713 // arrangement. Pieces of different sections admitted by a hug onto the
2714 // same face, each lying on the other's curve, are one split, and the
2715 // section covering the most of it speaks for it.
2716 {
2717 let width = tol.confusion() * 1e3;
2718 let mut drop: Vec<usize> = Vec::new();
2719 let hugged_onto: Vec<usize> = pieces
2720 .iter()
2721 .enumerate()
2722 .filter(|(_, p)| p.hug_key.is_some())
2723 .map(|(i, _)| i)
2724 .collect();
2725 let mid_of = |p: &SectionPiece| -> OgeomResult<Point> {
2726 let section = §ions[p.section];
2727 let domain = section.curve.domain();
2728 section.curve.point_at(
2729 at_param(f64::midpoint(p.range.0, p.range.1), domain, section.closed),
2730 tol,
2731 )
2732 };
2733 let length_of = |p: &SectionPiece| -> OgeomResult<f64> {
2734 let section = §ions[p.section];
2735 let domain = section.curve.domain();
2736 let a = section
2737 .curve
2738 .point_at(at_param(p.range.0, domain, section.closed), tol)?;
2739 let b = section
2740 .curve
2741 .point_at(at_param(p.range.1, domain, section.closed), tol)?;
2742 Ok(a.distance(b))
2743 };
2744 // A hug-admitted piece against every other piece kept on the same
2745 // face: a section admitted the ordinary way, inside both faces,
2746 // already speaks for the split, and the hug-admitted one yields
2747 // to it; two hug-admitted ones yield to the longer.
2748 for &x in &hugged_onto {
2749 let px = &pieces[x];
2750 let Some((_, target_from_a, target_face)) = px.hug_key else {
2751 continue;
2752 };
2753 for (y, py) in pieces.iter().enumerate() {
2754 if y == x || py.section == px.section || drop.contains(&x) || drop.contains(&y) {
2755 continue;
2756 }
2757 let sy = §ions[py.section];
2758 let on_target = if target_from_a {
2759 sy.face_a == target_face && !py.hugs[0]
2760 } else {
2761 sy.face_b == target_face && !py.hugs[1]
2762 };
2763 if !on_target {
2764 continue;
2765 }
2766 let sx = §ions[px.section];
2767 // A closed section's pieces are ranged past its domain end
2768 // and folded on use; the distance helper samples the range
2769 // it is given, so it is given the folded one.
2770 let stretch = |section: &SectionRec, range: (f64, f64)| -> (f64, f64) {
2771 folded_range(range, section.curve.domain(), section.closed)
2772 };
2773 let on_y =
2774 distance_to_edge_curve(&sy.curve, stretch(sy, py.range), mid_of(px)?, tol)?
2775 <= width;
2776 let on_x =
2777 distance_to_edge_curve(&sx.curve, stretch(sx, px.range), mid_of(py)?, tol)?
2778 <= width;
2779 if !(on_x && on_y) {
2780 continue;
2781 }
2782 let loser = if py.hug_key.is_some() && length_of(px)? > length_of(py)? {
2783 y
2784 } else {
2785 x
2786 };
2787 if *DEBUG_WIRE {
2788 eprintln!(
2789 "PAVE: s{} and s{} split one face along one line; s{} speaks for it",
2790 px.section,
2791 py.section,
2792 pieces[if loser == x { y } else { x }].section
2793 );
2794 }
2795 drop.push(loser);
2796 if loser == x {
2797 break;
2798 }
2799 }
2800 }
2801 let mut index = 0_usize;
2802 pieces.retain(|_| {
2803 let keep = !drop.contains(&index);
2804 index += 1;
2805 keep
2806 });
2807 }
2808 // The audit's verdict. A dropped pair whose surfaces intersect is not a
2809 // filter bug: planes meet along an infinite line the paving then trims
2810 // to the faces, usually to nothing. A dropped pair whose section
2811 // *survives paving* is: some of that curve lies inside both faces, so
2812 // the faces genuinely meet and the filter's boxes failed to. Contacts
2813 // need no check: a contact is an owner edge lying in the target face,
2814 // which forces the boxes to overlap where the edge does.
2815 //
2816 // This membership check is the audit's first tooth; the second is the
2817 // strict replay in general_fuse, which runs the *filtered* fill for the
2818 // production result and diffs this unfiltered one against it, so the
2819 // non-compositionality of paving (a section's kept intervals see the
2820 // other sections' paves) is caught rather than stated as a limit.
2821 if admit_all {
2822 for piece in &pieces {
2823 let section = §ions[piece.section];
2824 let (fa, fb) = (&ga.faces[section.face_a], &gb.faces[section.face_b]);
2825 assert!(
2826 fa.bound.intersects(&fb.bound),
2827 "bound filter audit: faces {}/{} were dropped by the bound \
2828 filter, yet their section paved a surviving piece over \
2829 {:?}; the filter under-approximates",
2830 section.face_a,
2831 section.face_b,
2832 piece.range,
2833 );
2834 }
2835 }
2836 // A contact edge splits where it crosses the target face's boundary,
2837 // and that crossing is a pave on *both* edges, so the owner's own face
2838 // splits its boundary consistently and the pieces sew back shared.
2839 let mut contact_along: Vec<Vec<(f64, f64)>> = vec![Vec::new(); contacts.len()];
2840 for (ci, contact) in contacts.iter().enumerate() {
2841 let target = if contact.target_from_a {
2842 &ga.faces[contact.target_face]
2843 } else {
2844 &gb.faces[contact.target_face]
2845 };
2846 // A fitted contact sits off exact geometry by its own tolerance, and
2847 // the crossings it genuinely makes gape by the same: the filter and
2848 // the finder both widen, or a fitted rail never registers against
2849 // the exact seam it crosses.
2850 let reach = tol.confusion().max(contact.tolerance * 2.0);
2851 let cc = CurveCurveOptions {
2852 gap: reach.max(CurveCurveOptions::default().gap),
2853 ..CurveCurveOptions::default()
2854 };
2855 for e in &target.edges {
2856 let found = intersect_curves(&contact.curve, &e.curve, cc, tol)?;
2857 for crossing in &found.crossings {
2858 if crossing.gap > reach {
2859 continue;
2860 }
2861 if crossing.on_a < contact.crange.0 + tol.parametric()
2862 || crossing.on_a > contact.crange.1 - tol.parametric()
2863 {
2864 continue;
2865 }
2866 // Touching is not crossing, at curve level as at surface
2867 // level: where the two curves run nearly parallel (a blend
2868 // arc meeting the edge it is tangent to), the "crossing" is
2869 // numerical noise smeared along the contact, and paving it
2870 // would plant a vertex a hair off both curves.
2871 if tangential(&contact.curve, crossing.on_a, &e.curve, crossing.on_b, tol)? {
2872 continue;
2873 }
2874 let honesty = honest(contact.tolerance, tol).max(crossing.reach);
2875 paves.entry(contact.node).or_default().push(Pave {
2876 t: crossing.on_a,
2877 honesty,
2878 });
2879 let on_b = onto_range(crossing.on_b, &e.curve, e.crange, tol);
2880 if on_b > e.crange.0 + tol.parametric() && on_b < e.crange.1 - tol.parametric() {
2881 paves
2882 .entry(e.node)
2883 .or_default()
2884 .push(Pave { t: on_b, honesty });
2885 }
2886 }
2887 // A span of the contact running along a target boundary edge
2888 // splits nothing: it is already boundary on both sides
2889 // (identically stacked boxes are all such spans), and duplicating
2890 // it as a strand would cancel the boundary it copies.
2891 // The intersector answers coincidence in closed form for the
2892 // analytic pairs only; a fitted curve lying on an exact one
2893 // (a marched sphere's rim on a wedge cap's arc) comes back as
2894 // crossings or nothing. That span is measured instead: the
2895 // stretch of the contact within the pair's honesty of the edge.
2896 // An analytic overlap survives only where it lands inside the
2897 // contact's own window; a trimmed circle written a turn up
2898 // clips to nothing, and is then measured like a fitted one.
2899 let survives = |overlap: &ogeom_intersect::Overlap| -> bool {
2900 let (lo, hi) = if overlap.on_a.0 <= overlap.on_a.1 {
2901 overlap.on_a
2902 } else {
2903 (overlap.on_a.1, overlap.on_a.0)
2904 };
2905 let carried = if contact.curve.is_periodic() {
2906 let (dlo, dhi) = contact.curve.domain();
2907 let period = dhi - dlo;
2908 let turn = ((contact.crange.0 - lo) / period).floor();
2909 [turn, turn + 1.0, turn - 1.0]
2910 .into_iter()
2911 .map(|k| (k.mul_add(period, lo), k.mul_add(period, hi)))
2912 .map(|(a, b)| b.min(contact.crange.1) - a.max(contact.crange.0))
2913 .fold(f64::NEG_INFINITY, f64::max)
2914 } else {
2915 hi.min(contact.crange.1) - lo.max(contact.crange.0)
2916 };
2917 carried > tol.parametric()
2918 };
2919 // The closed-form overlap is between the two *curves*; the
2920 // stretch that is boundary is what the *edge* covers of it. A
2921 // box cut from an L-bracket flush with the bracket's wall puts
2922 // the box's wall-side edge on the line of the end face's own
2923 // edge along the wall, a length below it: read as along that
2924 // edge over the whole curve, the strip's side was never paved
2925 // and the end face kept the strip. Clipped through the
2926 // overlap's own correspondence so the carry below stays affine.
2927 let clipped: Vec<ogeom_intersect::Overlap> = found
2928 .overlaps
2929 .iter()
2930 .filter_map(|overlap| {
2931 let (lo, hi) = overlap_within(overlap, e.crange, &contact.curve, tol)?;
2932 let span = overlap.on_a.1 - overlap.on_a.0;
2933 if span.abs() <= f64::MIN_POSITIVE {
2934 return None;
2935 }
2936 let to_b = |t: f64| {
2937 overlap.on_b.0
2938 + (overlap.on_b.1 - overlap.on_b.0) * (t - overlap.on_a.0) / span
2939 };
2940 Some(ogeom_intersect::Overlap {
2941 on_a: (lo, hi),
2942 on_b: (to_b(lo), to_b(hi)),
2943 })
2944 })
2945 .collect();
2946 // A line and a curved conic share no stretch, however loosely
2947 // either is held: a straight edge tangent to an arc stays within
2948 // the measuring width of it for a short run either side of the
2949 // touch, and read as a span there it splits the arc at a vertex
2950 // the face across the arc never has.
2951 let conic = |c: &Curve| {
2952 matches!(
2953 c,
2954 Curve::Circle(_) | Curve::Ellipse(_) | Curve::Hyperbola(_) | Curve::Parabola(_)
2955 )
2956 };
2957 let straight_on_conic = (matches!(contact.curve, Curve::Line(_)) && conic(&e.curve))
2958 || (conic(&contact.curve) && matches!(e.curve, Curve::Line(_)));
2959 let measured: Vec<ogeom_intersect::Overlap> =
2960 if clipped.iter().any(survives) || straight_on_conic {
2961 Vec::new()
2962 } else {
2963 measured_overlaps(&contact.curve, contact.crange, contact.tolerance, e, tol)?
2964 };
2965 for overlap in clipped.iter().chain(measured.iter()) {
2966 let ordered = |r: (f64, f64)| if r.0 <= r.1 { r } else { (r.1, r.0) };
2967 let (lo, hi) = ordered(overlap.on_a);
2968 // The overlap is between the two *curves*; what interferes is
2969 // the stretch both *edges* actually cover. A hole's arc and
2970 // the disc that fills it lie on one circle, so the curves
2971 // overlap over the whole turn while the arc covers three
2972 // quarters of it), and paving at the turn's ends says nothing,
2973 // where paving at the arc's ends is exactly the split the
2974 // other side needs to sew against.
2975 // A periodic curve's overlap is answered on its base turn;
2976 // an edge written a turn up (a wedge cap's arc at 2π..2.5π)
2977 // covers it only once the turn is carried across.
2978 let (lo, hi) = if contact.curve.is_periodic() {
2979 let (dlo, dhi) = contact.curve.domain();
2980 let period = dhi - dlo;
2981 let turn = ((contact.crange.0 - lo) / period).floor();
2982 let mut best = (lo, hi);
2983 let mut best_span = f64::NEG_INFINITY;
2984 for k in [turn, turn + 1.0, turn - 1.0] {
2985 let cand = (k.mul_add(period, lo), k.mul_add(period, hi));
2986 let span = cand.1.min(contact.crange.1) - cand.0.max(contact.crange.0);
2987 if span > best_span {
2988 best_span = span;
2989 best = cand;
2990 }
2991 }
2992 best
2993 } else {
2994 (lo, hi)
2995 };
2996 let (lo, hi) = (lo.max(contact.crange.0), hi.min(contact.crange.1));
2997 if hi - lo <= tol.parametric() {
2998 continue;
2999 }
3000 for t in [lo, hi] {
3001 paves.entry(contact.node).or_default().push(Pave {
3002 t,
3003 honesty: contact.tolerance,
3004 });
3005 }
3006 if *DEBUG_WIRE {
3007 eprintln!(
3008 "CONTACT c{ci} along edge {} over ({lo:.6}, {hi:.6}) of {:?}: {:?} .. {:?}",
3009 e.node.index(),
3010 contact.crange,
3011 contact.curve.point_at(lo, tol).ok(),
3012 contact.curve.point_at(hi, tol).ok()
3013 );
3014 }
3015 contact_along[ci].push((lo, hi));
3016 // The *target* edge splits where the shared stretch ends,
3017 // exactly as the contact does. Without this, the face across
3018 // the overlap keeps one long boundary edge where its new
3019 // neighbours carry two short ones, and sew (which matches
3020 // edges whole) can pair it with neither. The clamped ends are
3021 // carried across by the correspondence the overlap itself
3022 // states, which is affine over the shared stretch.
3023 let span = overlap.on_a.1 - overlap.on_a.0;
3024 let carry = |t: f64| -> f64 {
3025 if span.abs() <= f64::MIN_POSITIVE {
3026 overlap.on_b.0
3027 } else {
3028 overlap.on_b.0
3029 + (overlap.on_b.1 - overlap.on_b.0) * (t - overlap.on_a.0) / span
3030 }
3031 };
3032 let (mut tlo, mut thi) = (carry(lo), carry(hi));
3033 if tlo > thi {
3034 core::mem::swap(&mut tlo, &mut thi);
3035 }
3036 let target_domain = e.curve.domain();
3037 let periodic = e.curve.is_periodic();
3038 for t in [tlo, thi] {
3039 // A correspondence across a full turn can run the
3040 // parameter past the domain; the pave belongs where the
3041 // edge actually is.
3042 let t = if periodic { fold(t, target_domain) } else { t };
3043 if t > e.crange.0 + tol.parametric() && t < e.crange.1 - tol.parametric() {
3044 paves.entry(e.node).or_default().push(Pave {
3045 t,
3046 honesty: contact.tolerance,
3047 });
3048 }
3049 }
3050 }
3051 }
3052 }
3053
3054 let mut same_a: Vec<Vec<usize>> = vec![Vec::new(); ga.faces.len()];
3055 let mut same_b: Vec<Vec<usize>> = vec![Vec::new(); gb.faces.len()];
3056 for (ia, ib) in same_pairs {
3057 same_a[ia].push(ib);
3058 same_b[ib].push(ia);
3059 }
3060 Ok((
3061 sections,
3062 pieces,
3063 contacts,
3064 tangents,
3065 contact_along,
3066 paves,
3067 same_a,
3068 same_b,
3069 hug_junctions,
3070 ))
3071}
3072
3073/// An exact section cut at the chart degeneracies it runs through, each
3074/// piece carrying exact pcurves on both faces.
3075///
3076/// The degeneracies are not guessed from the surfaces: they are the faces'
3077/// own *pole edges*, the degenerate edges the topology already carries, so a
3078/// face whose chart has a pole says so and one that has none costs nothing.
3079///
3080/// `None` means the split is no use here (no degeneracy on the curve, or a
3081/// piece whose projection still has no closed form), and the caller falls
3082/// back to marching, which is the honest answer rather than a fitted pcurve
3083/// pretending to be exact.
3084fn split_at_degeneracies(
3085 curve: &Curve,
3086 fa: &GFace,
3087 fb: &GFace,
3088 tol: Tolerances,
3089) -> OgeomResult<Option<Vec<(Curve, PlanarCurve, PlanarCurve)>>> {
3090 let mut stops: Vec<f64> = Vec::new();
3091 let domain = curve.domain();
3092 // The split points are the *surfaces'* chart degeneracies, not merely the
3093 // faces' pole edges: a meridian section runs through both of a sphere's
3094 // poles, and a face that owns only the north one still cannot chart an
3095 // arc that wraps through the south. The surface knows where its chart
3096 // collapses whether or not the face's trim reaches there.
3097 let mut candidates: Vec<Point> = Vec::new();
3098 for face in [fa, fb] {
3099 for pole in &face.poles {
3100 candidates.push(pole.point);
3101 }
3102 match &face.surface {
3103 SurfaceGeometry::Sphere(s) => {
3104 let sphere = s.sphere();
3105 let axis = sphere.frame().z().vector();
3106 candidates.push(sphere.centre() + axis * sphere.radius());
3107 candidates.push(sphere.centre() - axis * sphere.radius());
3108 }
3109 SurfaceGeometry::Cone(c) => {
3110 candidates.push(c.cone().apex());
3111 }
3112 _ => {}
3113 }
3114 }
3115 for point in candidates {
3116 let found = ogeom_algo::project_on_curve(curve, point, 256, tol)?;
3117 if found.distance <= tol.confusion() {
3118 stops.push(found.parameter);
3119 }
3120 }
3121 if stops.is_empty() {
3122 return Ok(None);
3123 }
3124 stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
3125 stops.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
3126
3127 // A closed curve is cut into the arcs between successive stops, the last
3128 // wrapping past the domain end; an open one keeps its own ends as stops.
3129 let closed = curve.is_closed(tol) || curve.is_periodic();
3130 let mut arcs: Vec<(f64, f64)> = Vec::new();
3131 if closed {
3132 if stops.len() < 2 {
3133 // One stop on a closed curve leaves one arc, from the stop right
3134 // round to itself, which still has the pole at both ends.
3135 let period = domain.1 - domain.0;
3136 arcs.push((stops[0], stops[0] + period));
3137 } else {
3138 let period = domain.1 - domain.0;
3139 for i in 0..stops.len() {
3140 let lo = stops[i];
3141 let hi = if i + 1 < stops.len() {
3142 stops[i + 1]
3143 } else {
3144 stops[0] + period
3145 };
3146 arcs.push((lo, hi));
3147 }
3148 }
3149 } else {
3150 let mut cuts = vec![domain.0];
3151 cuts.extend(
3152 stops
3153 .iter()
3154 .copied()
3155 .filter(|t| *t > domain.0 + tol.parametric() && *t < domain.1 - tol.parametric()),
3156 );
3157 cuts.push(domain.1);
3158 for pair in cuts.windows(2) {
3159 arcs.push((pair[0], pair[1]));
3160 }
3161 }
3162
3163 let mut out = Vec::with_capacity(arcs.len());
3164 for (lo, hi) in arcs {
3165 if hi - lo <= tol.parametric() {
3166 continue;
3167 }
3168 let (Some(pa), Some(pb)) = (
3169 ogeom_intersect::exact_pcurve_over(curve, (lo, hi), &fa.surface, tol),
3170 ogeom_intersect::exact_pcurve_over(curve, (lo, hi), &fb.surface, tol),
3171 ) else {
3172 return Ok(None);
3173 };
3174 let Ok(piece) = ogeom_geom::TrimmedCurve::new(curve.clone(), lo, hi, tol) else {
3175 return Ok(None);
3176 };
3177 out.push((piece.into(), pa, pb));
3178 }
3179 if out.is_empty() {
3180 return Ok(None);
3181 }
3182 Ok(Some(out))
3183}
3184
3185/// A surface narrowed to the reach of a bound, for the marcher's benefit.
3186///
3187/// Seeding samples a surface's *parameter box*, so a plane stored over
3188/// ±10^9 (which is how an unbounded carrier reaches this code) is sampled
3189/// at a spacing a hundred million times anything it could be meeting, and
3190/// no seed ever lands near the curve. Narrowing to where the two faces
3191/// actually are is what lets the seeding see it. The chart is untouched:
3192/// only the window the marcher walks changes, so pcurves fitted in it mean
3193/// the same thing on the surface as stored.
3194fn windowed_to(surface: &SurfaceGeometry, bound: &ogeom_math::Aabb) -> SurfaceGeometry {
3195 let corners = bound.corners();
3196 if corners.is_empty() {
3197 return surface.clone();
3198 }
3199 match surface {
3200 SurfaceGeometry::Plane(p) => {
3201 let frame = p.plane().frame();
3202 let (mut u0, mut u1, mut v0, mut v1) = (
3203 f64::INFINITY,
3204 f64::NEG_INFINITY,
3205 f64::INFINITY,
3206 f64::NEG_INFINITY,
3207 );
3208 for c in &corners {
3209 let local = frame.to_local(*c);
3210 u0 = u0.min(local.x);
3211 u1 = u1.max(local.x);
3212 v0 = v0.min(local.y);
3213 v1 = v1.max(local.y);
3214 }
3215 let margin = ((u1 - u0) + (v1 - v0)).mul_add(0.25, 1.0);
3216 let (want_u, want_v) = ((u0 - margin, u1 + margin), (v0 - margin, v1 + margin));
3217 let (have_u, have_v) = ogeom_geom::Surface::domain(p);
3218 if have_u.1 - have_u.0 <= want_u.1 - want_u.0
3219 && have_v.1 - have_v.0 <= want_v.1 - want_v.0
3220 {
3221 return surface.clone();
3222 }
3223 ogeom_geom::PlaneSurface::over(p.plane(), want_u, want_v)
3224 .map_or_else(|_| surface.clone(), Into::into)
3225 }
3226 SurfaceGeometry::Cylinder(c) => {
3227 let frame = c.cylinder().frame();
3228 let (mut h0, mut h1) = (f64::INFINITY, f64::NEG_INFINITY);
3229 for corner in &corners {
3230 let h = (*corner - frame.origin()).dot(frame.z().vector());
3231 h0 = h0.min(h);
3232 h1 = h1.max(h);
3233 }
3234 let margin = (h1 - h0).mul_add(0.25, 1.0);
3235 let want = (h0 - margin, h1 + margin);
3236 let have = ogeom_geom::Surface::domain(c).1;
3237 if have.1 - have.0 <= want.1 - want.0 {
3238 return surface.clone();
3239 }
3240 ogeom_geom::CylinderSurface::new(c.cylinder(), want)
3241 .map_or_else(|_| surface.clone(), Into::into)
3242 }
3243 other => other.clone(),
3244 }
3245}
3246
3247/// March a pair whose exact section has no closed-form pcurve.
3248fn march_pair(
3249 a: &SurfaceGeometry,
3250 b: &SurfaceGeometry,
3251 options: &ogeom_intersect::IntersectOptions,
3252 tol: Tolerances,
3253) -> OgeomResult<Vec<ogeom_intersect::IntersectionCurve>> {
3254 use ogeom_intersect::{approximate_branch, branches};
3255 let traced = branches(a, b, options.marching, tol)?;
3256 let mut out = Vec::new();
3257 for branch in &traced {
3258 out.push(approximate_branch(a, b, branch, options.tolerance, tol)?);
3259 }
3260 if out.is_empty() {
3261 // No branch found is two different stories. Surfaces that measurably
3262 // stand apart over their stated extents (a blend's cylinder and a
3263 // far-off bevel plane whose ellipse of intersection lies beyond
3264 // both) simply do not interact, and an empty section is the true
3265 // answer. Only a pair that comes close and still resolves nothing
3266 // is beyond the intersector.
3267 if surfaces_stand_apart(a, b, tol) {
3268 return Ok(out);
3269 }
3270 ogeom_bail!(
3271 NotDone,
3272 "an exact section has no closed-form pcurve and marching resolved \
3273 no branch; the configuration is beyond the intersector's current \
3274 reach"
3275 );
3276 }
3277 Ok(out)
3278}
3279
3280/// Whether two surfaces measurably keep their distance over their stated
3281/// extents.
3282///
3283/// A conservative grid measurement: sample the smaller-extent surface and
3284/// project each sample onto the other; apart means every sample clears a
3285/// margin scaled to the extents. Surfaces with unbounded or enormous stated
3286/// domains (an imported plane's billion units) are never called apart this
3287/// way, because a grid over them samples nothing.
3288fn surfaces_stand_apart(a: &SurfaceGeometry, b: &SurfaceGeometry, tol: Tolerances) -> bool {
3289 use ogeom_geom::Surface as _;
3290 let extent_of = |s: &SurfaceGeometry| -> f64 {
3291 let ((ua, ub), (va, vb)) = s.domain();
3292 (ub - ua).abs().max((vb - va).abs())
3293 };
3294 let (sample, against) = if extent_of(a) <= extent_of(b) {
3295 (a, b)
3296 } else {
3297 (b, a)
3298 };
3299 let span = extent_of(sample);
3300 if !span.is_finite() || span > 1e4 {
3301 return false;
3302 }
3303 let ((ua, ub), (va, vb)) = sample.domain();
3304 const GRID: usize = 9;
3305 // One seeding grid over the far surface, asked a hundred times: the
3306 // same seeds and the same Newton the per-call projection would use, so
3307 // the verdict is bit-identical, at hundreds of evaluations instead of
3308 // tens of thousands for every genuine near-miss.
3309 let Ok(seeds) = ogeom_algo::SurfaceSeeds::over(against, 16, tol) else {
3310 return false;
3311 };
3312 let mut clearance = f64::INFINITY;
3313 for i in 0..=GRID {
3314 for j in 0..=GRID {
3315 #[allow(clippy::cast_precision_loss)]
3316 let u = ua + (ub - ua) * i as f64 / GRID as f64;
3317 #[allow(clippy::cast_precision_loss)]
3318 let v = va + (vb - va) * j as f64 / GRID as f64;
3319 let Ok(p) = sample.point_at(u, v, tol) else {
3320 return false;
3321 };
3322 let Ok(projection) = seeds.project(against, p, tol) else {
3323 return false;
3324 };
3325 clearance = clearance.min(projection.distance);
3326 if clearance <= tol.confusion() * 1e3 {
3327 return false;
3328 }
3329 }
3330 }
3331 clearance > tol.confusion() * 1e3
3332}
3333
3334/// Whether two surfaces are one surface wherever they overlap, measured.
3335///
3336/// The closed forms answer this for the pairs they know. Where there is no
3337/// closed form it does not stop being a fair question (two patches restated
3338/// from one plane are the same surface, and nothing in their control points
3339/// says so), but it stops being answerable exactly, so it is measured here
3340/// and only for the pairs the analytic layer has already declined.
3341///
3342/// Sampled on the *smaller* window, because the answer is about the region
3343/// the two share and a stated window is not that region: a plane's own
3344/// extends for a billion units either way, and a grid over it samples
3345/// nothing. A sample whose foot lands on the rim of the other is skipped
3346/// rather than counted against: the other patch simply does not reach that
3347/// far, and a distance measured to its rim is about the window, not the
3348/// surface.
3349///
3350/// One-sided by construction: a pair that crosses puts interior samples well
3351/// off the other, so it cannot pass, and a pair this cannot resolve marches
3352/// exactly as it did before.
3353fn surfaces_coincide(
3354 a: &SurfaceGeometry,
3355 b: &SurfaceGeometry,
3356 reach: f64,
3357 tol: Tolerances,
3358) -> bool {
3359 use ogeom_geom::Surface as _;
3360 /// Samples per direction over the window, and how many must land inside
3361 /// the other before agreement means anything.
3362 const GRID: usize = 6;
3363 const EVIDENCE: usize = 4;
3364
3365 let span = |s: &SurfaceGeometry| -> f64 {
3366 let ((ua, ub), (va, vb)) = s.domain();
3367 (ub - ua).abs().max((vb - va).abs())
3368 };
3369 let (sampled, against) = if span(a) <= span(b) { (a, b) } else { (b, a) };
3370 let ((ua, ub), (va, vb)) = sampled.domain();
3371 if !(ua.is_finite() && ub.is_finite() && va.is_finite() && vb.is_finite()) {
3372 return false;
3373 }
3374 let ((wu0, wu1), (wv0, wv1)) = against.domain();
3375 // A twentieth of the window in from each rim: enough that a foot the
3376 // search pinned to the rim is not read as one the surface truly reaches.
3377 let (mu, mv) = ((wu1 - wu0) * 0.05, (wv1 - wv0) * 0.05);
3378
3379 let mut evidence = 0_usize;
3380 for i in 0..=GRID {
3381 for j in 0..=GRID {
3382 #[allow(clippy::cast_precision_loss)]
3383 let u = ua + (ub - ua) * (i as f64 / GRID as f64);
3384 #[allow(clippy::cast_precision_loss)]
3385 let v = va + (vb - va) * (j as f64 / GRID as f64);
3386 let Ok(p) = sampled.point_at(u, v, tol) else {
3387 return false;
3388 };
3389 let Ok(foot) = ogeom_algo::project_on_surface(against, p, 16, tol) else {
3390 return false;
3391 };
3392 let (fu, fv) = foot.parameters;
3393 if fu <= wu0 + mu || fu >= wu1 - mu || fv <= wv0 + mv || fv >= wv1 - mv {
3394 continue;
3395 }
3396 if foot.distance > reach {
3397 return false;
3398 }
3399 evidence += 1;
3400 }
3401 }
3402 evidence >= EVIDENCE
3403}
3404
3405// --- the general fuse --------------------------------------------------------
3406
3407/// One piece of one argument face, classified against the other argument.
3408struct FacePiece {
3409 /// Which argument's face list, and which face.
3410 from_a: bool,
3411 face: usize,
3412 rings: Vec<Vec<Traversal<Tag>>>,
3413 /// The rings as chart polylines, for the coincidence pairing below.
3414 outlines: Vec<Vec<Point2>>,
3415 /// The interior point the state was decided at, in space.
3416 probe: Point,
3417 state: PieceState,
3418 /// Whether the *other* argument already contributes this same patch of
3419 /// this same surface.
3420 ///
3421 /// Two faces lying on one surface with their material on the same side
3422 /// bound the union and the intersection once between them, not twice,
3423 /// but "once" is a statement about the *patch*, not about which argument
3424 /// it came from. Where a tool's cap fills a hole its own bore left, the
3425 /// part has no piece there at all, and dropping the tool's by argument
3426 /// identity leaves the result with a hole in it. So the duplicate is
3427 /// identified by containment, and only a piece genuinely stood in for is
3428 /// dropped.
3429 covered: bool,
3430}
3431
3432struct GeneralFused {
3433 a: GSolid,
3434 b: GSolid,
3435 sections: Vec<SectionRec>,
3436 contacts: Vec<ContactRec>,
3437 /// Curves the two boundaries touch along without crossing. They take no
3438 /// part in the classification below (that is what tangency means) and
3439 /// are carried for the consumers that want the contact itself.
3440 tangents: Vec<TangentRec>,
3441 pieces: Vec<FacePiece>,
3442 /// Junctions several paves describe, each resolved once for every
3443 /// strand that ends in it.
3444 junctions: Vec<Junction>,
3445}
3446
3447/// One junction the paving found several times over.
3448///
3449/// A tolerant rail meeting a wedge's faces near one corner collects a
3450/// cluster of crossings inside its own stated radius: each face's section
3451/// stops at its own crossing with the rail, and the crossings sit a few
3452/// tenths of a micron to a few hundred apart along it. The rail's strands
3453/// split once per cluster, at its first pave; the sections' ends still name
3454/// their own crossings; and the two descriptions of the junction can sit
3455/// apart by the cluster's whole span, which no single strand's honesty
3456/// covers. So the junction is one vertex standing at the first pave, owning
3457/// the span the paves disagree by, and every strand end inside that span
3458/// names it, whichever pave its own trim stopped at.
3459#[derive(Debug, Clone, Copy)]
3460struct Junction {
3461 /// Where the cluster's first pave sits.
3462 at: Point,
3463 /// How far a strand end may sit from `at` and still be this junction:
3464 /// the cluster's span plus the rail's own reach.
3465 reach: f64,
3466 /// Whether `at` is an edge's own end vertex that a section tangent to
3467 /// the edge was welded onto: the section's chart image is bent onto it
3468 /// rather than left where the section stopped.
3469 onto_vertex: bool,
3470}
3471
3472/// Junctions whose balls overlap, merged transitively into one junction
3473/// each: at the members' centroid, reaching as far as the farthest member's
3474/// own reach extends from it.
3475fn merge_junctions(junctions: Vec<Junction>) -> Vec<Junction> {
3476 let n = junctions.len();
3477 let mut parent: Vec<usize> = (0..n).collect();
3478 fn root(parent: &mut [usize], mut i: usize) -> usize {
3479 while parent[i] != i {
3480 parent[i] = parent[parent[i]];
3481 i = parent[i];
3482 }
3483 i
3484 }
3485 // Swept along x: two balls overlap only if their centres are within
3486 // both reaches along every axis, so each junction is asked only of those
3487 // ahead of it by no more than its reach and the widest. The groups are
3488 // the same as asking every pair; a converted part carries tens of
3489 // thousands of junctions, and every pair was seconds.
3490 let widest = junctions.iter().map(|j| j.reach).fold(0.0_f64, f64::max);
3491 let mut order: Vec<usize> = (0..n).collect();
3492 order.sort_by(|&x, &y| junctions[x].at.x.total_cmp(&junctions[y].at.x));
3493 for (k, &i) in order.iter().enumerate() {
3494 let limit = junctions[i].at.x + junctions[i].reach + widest;
3495 for &j in &order[k + 1..] {
3496 if junctions[j].at.x > limit {
3497 break;
3498 }
3499 if junctions[i].at.distance(junctions[j].at) <= junctions[i].reach + junctions[j].reach
3500 {
3501 let (ri, rj) = (root(&mut parent, i), root(&mut parent, j));
3502 if ri != rj {
3503 parent[rj] = ri;
3504 }
3505 }
3506 }
3507 }
3508 let mut groups: Vec<Vec<usize>> = Vec::new();
3509 let mut of_root: Vec<Option<usize>> = vec![None; n];
3510 for i in 0..n {
3511 let r = root(&mut parent, i);
3512 match of_root[r] {
3513 Some(g) => groups[g].push(i),
3514 None => {
3515 of_root[r] = Some(groups.len());
3516 groups.push(vec![i]);
3517 }
3518 }
3519 }
3520 // A group is one junction only while it stays a junction's size: a
3521 // chain of overlapping balls along an edge would otherwise merge into
3522 // one reaching the chain's length, and the rebuild would weld the edge
3523 // into a point. A group reaching beyond four of its widest member stays
3524 // as it was.
3525 let mut out = Vec::with_capacity(groups.len());
3526 for members in groups {
3527 if members.len() == 1 {
3528 out.push(junctions[members[0]]);
3529 continue;
3530 }
3531 let mut sum = ogeom_math::Vector::ZERO;
3532 for &m in &members {
3533 sum += junctions[m].at - Point::ORIGIN;
3534 }
3535 #[allow(clippy::cast_precision_loss)]
3536 let at = Point::ORIGIN + sum / (members.len() as f64);
3537 let reach = members
3538 .iter()
3539 .map(|&m| at.distance(junctions[m].at) + junctions[m].reach)
3540 .fold(0.0_f64, f64::max);
3541 let widest = members
3542 .iter()
3543 .map(|&m| junctions[m].reach)
3544 .fold(0.0_f64, f64::max);
3545 if reach > widest * 4.0 {
3546 out.extend(members.iter().map(|&m| junctions[m]));
3547 } else {
3548 out.push(Junction {
3549 at,
3550 reach,
3551 onto_vertex: members.iter().any(|&m| junctions[m].onto_vertex),
3552 });
3553 }
3554 }
3555 out
3556}
3557
3558/// The junctions the paves describe more than once, or more loosely than
3559/// the edge itself, per edge in face order.
3560fn pave_junctions(
3561 ga: &GSolid,
3562 gb: &GSolid,
3563 paves: &std::collections::HashMap<EdgeKey, Vec<Pave>>,
3564 tol: Tolerances,
3565) -> OgeomResult<Vec<Junction>> {
3566 let mut seen: std::collections::HashSet<EdgeKey> = std::collections::HashSet::new();
3567 let mut junctions = Vec::new();
3568 for e in ga
3569 .faces
3570 .iter()
3571 .chain(gb.faces.iter())
3572 .flat_map(|f| f.edges.iter())
3573 {
3574 if !seen.insert(e.node) {
3575 continue;
3576 }
3577 let Some(ts) = paves.get(&e.node) else {
3578 continue;
3579 };
3580 // A tolerant edge (a merged facet group's boundary, a fitted rail)
3581 // is met by a section up to its own radius off it, so the section's
3582 // end and the edge's split point are one junction that far apart,
3583 // on either side of the edge.
3584 let tolerant = e.tolerance > tol.confusion() * 1e2;
3585 let floor = if tolerant {
3586 e.tolerance * 2.0
3587 } else {
3588 e.tolerance
3589 }
3590 .max(tol.confusion() * 10.0);
3591 for cluster in cluster_paves(&e.curve, e.crange, e.tolerance, ts, tol)? {
3592 if cluster.members > 1 || cluster.honesty > tol.confusion() * 1e2 || tolerant {
3593 junctions.push(Junction {
3594 at: cluster.at,
3595 reach: cluster.span + cluster.honesty.max(floor),
3596 onto_vertex: false,
3597 });
3598 }
3599 }
3600 }
3601 Ok(junctions)
3602}
3603
3604/// The face's outward normal at a chart point: the surface's, flipped when
3605/// the face presents its other side.
3606fn outward_normal(face: &GFace, at: Point2, tol: Tolerances) -> OgeomResult<ogeom_math::Vector> {
3607 let n = face.surface.normal_at(at.x, at.y, tol)?.vector();
3608 Ok(
3609 if face.face.orientation() == ogeom_topo::Orientation::Reversed {
3610 -n
3611 } else {
3612 n
3613 },
3614 )
3615}
3616
3617/// The chart point of a world point lying on a planar face, if it lands
3618/// inside the face's trim.
3619/// How far apart two of a face's edges may honestly end in its chart: the
3620/// loosest edge's tolerance, or the hug width where an edge is a fitted
3621/// section that once ran along another before parting from it.
3622fn outline_snap(face: &GFace, tol: Tolerances) -> f64 {
3623 face.edges
3624 .iter()
3625 .fold(tol.confusion() * 1e2, |acc, e| acc.max(e.tolerance * 2.0))
3626 .max(
3627 if face.edges.iter().any(|e| e.tolerance > tol.confusion()) {
3628 tol.confusion() * 1e3
3629 } else {
3630 0.0
3631 },
3632 )
3633}
3634
3635/// Close the gaps between a face's outline polylines.
3636///
3637/// The edges are polylined one by one, and where two of them meet at a
3638/// vertex that owns some tolerance (a fitted section's end welded to the
3639/// edge it hugged, a few dozen microns off), their polylines stop that far
3640/// apart. A ray cast for containment slips through such a gap, and a probe
3641/// standing inside the face within a hug's width of the seam reads as
3642/// outside. Ends within `snap` of another polyline's end are made one
3643/// point, so the outline is closed exactly as the topology says it is.
3644fn weld_outline_ends(lines: &mut [Vec<Point2>], snap: f64) {
3645 if snap <= 0.0 {
3646 return;
3647 }
3648 // Every polyline end, as (line, is its last point, where).
3649 let ends: Vec<(usize, bool, Point2)> = lines
3650 .iter()
3651 .enumerate()
3652 .flat_map(|(i, line)| {
3653 let first = line.first().map(|p| (i, false, *p));
3654 let last = line.last().map(|p| (i, true, *p));
3655 first.into_iter().chain(last)
3656 })
3657 .collect();
3658 // Each end moves onto the nearest other end within reach that sorts
3659 // before it, so a matched pair lands on one point rather than trading
3660 // places.
3661 let mut moves: Vec<(usize, bool, Point2)> = Vec::new();
3662 for &(i, end_i, p) in &ends {
3663 let nearest = ends
3664 .iter()
3665 // Ends closer than the parametric snap already meet for every
3666 // purpose here, and moving one by rounding noise takes a probe
3667 // that stands exactly on a chart's seam off it.
3668 .filter(|&&(j, end_j, q)| {
3669 let d = p.distance(q);
3670 (j, end_j) < (i, end_i) && j != i && d > PARAM_SNAP && d <= snap
3671 })
3672 .min_by(|a, b| {
3673 p.distance(a.2)
3674 .partial_cmp(&p.distance(b.2))
3675 .unwrap_or(core::cmp::Ordering::Equal)
3676 });
3677 if let Some(&(_, _, q)) = nearest {
3678 moves.push((i, end_i, q));
3679 }
3680 }
3681 for (i, end_i, q) in moves {
3682 let slot = if end_i {
3683 lines[i].last_mut()
3684 } else {
3685 lines[i].first_mut()
3686 };
3687 if let Some(p) = slot {
3688 *p = q;
3689 }
3690 }
3691}
3692
3693fn chart_point_of(face: &GFace, p: Point, tol: Tolerances) -> Option<Point2> {
3694 // Closed-form inversion for the analytic surfaces: the same-domain
3695 // resolution asks "where does this probe sit in the partner's chart", and
3696 // the partner may be any surface a face melts along: a plane against a
3697 // plane, but equally a wall band against the cylinder it copies. The
3698 // reach check keeps the answer honest: a point off the surface has no
3699 // chart position, whatever the inversion returns.
3700 use ogeom_math::elementary;
3701 let reach = tol.confusion() * 10.0;
3702 let raw = match &face.surface {
3703 SurfaceGeometry::Plane(x) => {
3704 let local = x.plane().frame().to_local(p);
3705 if local.z.abs() > reach {
3706 return None;
3707 }
3708 Point2::new(local.x, local.y)
3709 }
3710 SurfaceGeometry::Cylinder(x) => {
3711 let cylinder = x.cylinder();
3712 if cylinder.distance_to(p) > reach {
3713 return None;
3714 }
3715 let (u, v) = elementary::cylinder_parameters(&cylinder, p, tol).ok()?;
3716 Point2::new(u, v)
3717 }
3718 SurfaceGeometry::Cone(x) => {
3719 let cone = x.cone();
3720 if cone.distance_to(p) > reach {
3721 return None;
3722 }
3723 let (u, v) = elementary::cone_parameters(&cone, p, tol).ok()?;
3724 Point2::new(u, v)
3725 }
3726 SurfaceGeometry::Sphere(x) => {
3727 let sphere = x.sphere();
3728 if sphere.distance_to(p) > reach {
3729 return None;
3730 }
3731 let (u, v) = elementary::sphere_parameters(&sphere, p, tol).ok()?;
3732 Point2::new(u, v)
3733 }
3734 SurfaceGeometry::Torus(x) => {
3735 let torus = x.torus();
3736 if torus.distance_to(p) > reach {
3737 return None;
3738 }
3739 let (u, v) = elementary::torus_parameters(&torus, p, tol).ok()?;
3740 Point2::new(u, v)
3741 }
3742 // No closed form (a fitted patch, a swept or revolved surface)
3743 // inverts by projection, held to the same reach: a blend's leg on
3744 // a spline host is a partner like any other.
3745 other => {
3746 let foot = ogeom_algo::project_on_surface(other, p, 24, tol).ok()?;
3747 if foot.distance > reach {
3748 return None;
3749 }
3750 Point2::new(foot.parameters.0, foot.parameters.1)
3751 }
3752 };
3753 let at = fold_point_into_chart(raw, &face.surface);
3754 let mut lines: Vec<Vec<Point2>> = Vec::new();
3755 for e in &face.edges {
3756 lines.push(
3757 pcurve_polyline(&e.pcurve, e.prange, e.crange, e.crange, &face.surface, tol).ok()?,
3758 );
3759 // A seam bounds the chart twice (once per column), and a trim test
3760 // that sees only one side reads half the band as outside.
3761 if let Some((other, orange)) = &e.other_side {
3762 lines.push(
3763 pcurve_polyline(other, *orange, e.crange, e.crange, &face.surface, tol).ok()?,
3764 );
3765 }
3766 }
3767 weld_outline_ends(&mut lines, outline_snap(face, tol));
3768 let borrowed: Vec<&[Point2]> = lines.iter().map(Vec::as_slice).collect();
3769 // The face's boundary polylines are unwrapped (a winding ring may span
3770 // any one period's window, not necessarily the chart's canonical one,
3771 // and a wire chained onto one branch of the chart may sit whole periods
3772 // away from it), so the probe is carried to the outline's own branch
3773 // first, and asked at the neighbouring images as well.
3774 let mut shifts = vec![0.0];
3775 if face.surface.is_periodic_u() {
3776 let ((ua, ub), _) = face.surface.domain();
3777 if ub > ua {
3778 let period = ub - ua;
3779 let (sum, count) = lines
3780 .iter()
3781 .flatten()
3782 .fold((0.0_f64, 0_usize), |(s, n), q| (s + q.x, n + 1));
3783 #[allow(clippy::cast_precision_loss)]
3784 let centre = if count > 0 { sum / count as f64 } else { at.x };
3785 let home = ((centre - at.x) / period).round() * period;
3786 shifts = vec![home, home + period, home - period];
3787 }
3788 }
3789 for shift in shifts {
3790 let shifted = Point2::new(at.x + shift, at.y);
3791 if inside_many(&borrowed, shifted) {
3792 return Some(shifted);
3793 }
3794 }
3795 None
3796}
3797
3798/// Whether two curves meet tangentially at a crossing: the parallel-noise
3799/// gate for pave placement. One degree is far below any deliberate crossing
3800/// and far above the smear a tangency leaves in the general intersector.
3801fn tangential(a: &Curve, ta: f64, b: &Curve, tb: f64, tol: Tolerances) -> OgeomResult<bool> {
3802 let da = a.d1_at(ta, tol)?;
3803 let db = b.d1_at(tb, tol)?;
3804 let (ma, mb) = (da.magnitude(), db.magnitude());
3805 if ma <= tol.confusion() || mb <= tol.confusion() {
3806 return Ok(true);
3807 }
3808 Ok(da.cross(db).magnitude() / (ma * mb) < 2e-2)
3809}
3810
3811/// The chart image of an exact curve on a surface, fitted at the curve's
3812/// own parameters from its points inverted on the surface, with the
3813/// distance the image honestly sits off the curve, or nothing, when the
3814/// fit misses its budget by more than a chart's swing beside a pole allows.
3815fn fitted_image(
3816 curve: &Curve,
3817 surface: &SurfaceGeometry,
3818 budget: f64,
3819 tol: Tolerances,
3820) -> OgeomResult<Option<(PlanarCurve, f64)>> {
3821 use ogeom_geom::Surface as _;
3822 const SAMPLES: usize = 96;
3823 let (lo, hi) = curve.domain();
3824 if !hi.is_finite() || !lo.is_finite() || hi <= lo {
3825 return Ok(None);
3826 }
3827 let ((ua, ub), (va, vb)) = surface.domain();
3828 let periods = (
3829 if surface.is_periodic_u() {
3830 ub - ua
3831 } else {
3832 0.0
3833 },
3834 if surface.is_periodic_v() {
3835 vb - va
3836 } else {
3837 0.0
3838 },
3839 );
3840 // The image sampled where it moves: beside a pole a circle's image
3841 // swings half a turn within a hair of arc, and a uniform sampling puts
3842 // one point in the swing. Between any two samples whose images sit
3843 // further apart than a small step of the chart a sample is added, until
3844 // the image is walked at that step or the budget of samples is spent.
3845 let invert = |t: f64, guess: Option<(f64, f64)>| -> OgeomResult<Option<((f64, f64), f64)>> {
3846 let p = curve.point_at(t, tol)?;
3847 // A plane's image is its frame's own coordinates, exact and
3848 // unbounded: the face's chart window is a trim, not a limit on
3849 // where the curve may be read, and a projection clamped to it puts
3850 // the circle's far side off the surface by the window's shortfall.
3851 if let SurfaceGeometry::Plane(plane) = surface {
3852 let local = plane.plane().frame().to_local(p);
3853 let back = plane
3854 .plane()
3855 .frame()
3856 .to_world(ogeom_math::Point::new(local.x, local.y, 0.0));
3857 return Ok(Some(((local.x, local.y), back.distance(p))));
3858 }
3859 // A sphere's image is its frame's longitude and latitude, exact
3860 // where the projection's Newton cannot refine beside a pole and
3861 // hands back a coarse scan sample instead; the reading is checked
3862 // round the trip and left to the projection if the chart's
3863 // convention is not this one.
3864 if let SurfaceGeometry::Sphere(ball) = surface {
3865 let frame = ball.sphere().frame();
3866 let local = frame.to_local(p);
3867 let flat = local.x.hypot(local.y);
3868 // Kept a hair inside the poles: a spline fitted through points
3869 // on the pole itself overshoots it between them, and the chart
3870 // refuses a latitude past its end.
3871 let half = core::f64::consts::FRAC_PI_2 - 1e-6;
3872 let v = local.z.atan2(flat).clamp(-half, half);
3873 let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
3874 let back = surface.point_at(u, v, tol)?;
3875 if back.distance(p) <= budget.max(tol.confusion() * 1e2) {
3876 return Ok(Some(((u, v), back.distance(p))));
3877 }
3878 }
3879 let mut foot = match guess {
3880 Some(g) => ogeom_algo::project_on_surface_from(surface, p, g, tol)?,
3881 None => ogeom_algo::project_on_surface(surface, p, 24, tol)?,
3882 };
3883 if foot.distance > budget.max(tol.confusion() * 1e2) && guess.is_some() {
3884 foot = ogeom_algo::project_on_surface(surface, p, 48, tol)?;
3885 }
3886 if foot.distance > budget.max(tol.confusion() * 1e2) {
3887 if *DEBUG_WIRE {
3888 eprintln!(
3889 "IMAGE FIT: a point sits {:.2e} off the surface; no image",
3890 foot.distance
3891 );
3892 }
3893 return Ok(None);
3894 }
3895 Ok(Some((foot.parameters, foot.distance)))
3896 };
3897 let unwrap = |uv: (f64, f64), before: Option<Point2>| -> Point2 {
3898 let mut uv = Point2::new(uv.0, uv.1);
3899 if let Some(prev) = before {
3900 for (coord, period, before) in [
3901 (&mut uv.x, periods.0, prev.x),
3902 (&mut uv.y, periods.1, prev.y),
3903 ] {
3904 if period > 0.0 {
3905 while *coord - before > period / 2.0 {
3906 *coord -= period;
3907 }
3908 while before - *coord > period / 2.0 {
3909 *coord += period;
3910 }
3911 }
3912 }
3913 }
3914 uv
3915 };
3916 let mut samples: Vec<(f64, Point2)> = Vec::with_capacity(SAMPLES + 1);
3917 let mut guess: Option<(f64, f64)> = None;
3918 for k in 0..=SAMPLES {
3919 #[allow(clippy::cast_precision_loss)]
3920 let t = lo + (hi - lo) * (k as f64) / (SAMPLES as f64);
3921 let Some((uv, _)) = invert(t, guess)? else {
3922 return Ok(None);
3923 };
3924 guess = Some(uv);
3925 let before = samples.last().map(|(_, p)| *p);
3926 samples.push((t, unwrap(uv, before)));
3927 }
3928 let step = {
3929 let (su, sv) = (
3930 if periods.0 > 0.0 { periods.0 } else { ub - ua },
3931 if periods.1 > 0.0 { periods.1 } else { vb - va },
3932 );
3933 su.min(sv) * 0.01
3934 };
3935 const CAP: usize = 4096;
3936 let mut refined = true;
3937 while refined && samples.len() < CAP {
3938 refined = false;
3939 let mut next: Vec<(f64, Point2)> = Vec::with_capacity(samples.len() * 2);
3940 for pair in samples.windows(2) {
3941 let ((t0, p0), (t1, p1)) = (pair[0], pair[1]);
3942 next.push((t0, p0));
3943 if p0.distance(p1) > step && t1 - t0 > (hi - lo) * 1e-7 && next.len() < CAP {
3944 let tm = f64::midpoint(t0, t1);
3945 let Some((uv, _)) = invert(tm, Some((p0.x, p0.y)))? else {
3946 return Ok(None);
3947 };
3948 next.push((tm, unwrap(uv, Some(p0))));
3949 refined = true;
3950 }
3951 }
3952 next.push(samples[samples.len() - 1]);
3953 samples = next;
3954 }
3955 let params: Vec<f64> = samples.iter().map(|(t, _)| *t).collect();
3956 let image: Vec<Point2> = samples.iter().map(|(_, p)| *p).collect();
3957 let fitted =
3958 ogeom_geom::fit::fit_points_2d_at(¶ms, &image, 3, tol.confusion() * 10.0, tol)?;
3959 let planar: PlanarCurve = fitted.curve.into();
3960 // The honest error is in space: the surface read through the image
3961 // against the curve itself, between the samples as well as at them.
3962 // Checked at the samples and between each pair of them: where the
3963 // image swings the samples are dense, and a uniform check would step
3964 // over the swing and read the fit as honest.
3965 // An image that leaves the chart between its samples (a spline
3966 // overshooting the pole it was fitted up to) is no image either.
3967 let mut off = 0.0_f64;
3968 let mut checks: Vec<f64> = Vec::with_capacity(params.len() * 2);
3969 for pair in params.windows(2) {
3970 checks.push(pair[0]);
3971 checks.push(f64::midpoint(pair[0], pair[1]));
3972 }
3973 if let Some(&t) = params.last() {
3974 checks.push(t);
3975 }
3976 for t in checks {
3977 let uv = planar.point_at(t, tol)?;
3978 let Ok(on_surface) = surface.point_at(uv.x, uv.y, tol) else {
3979 if *DEBUG_WIRE {
3980 eprintln!("IMAGE FIT: the image leaves the chart at {uv:?}; no image");
3981 }
3982 return Ok(None);
3983 };
3984 off = off.max(on_surface.distance(curve.point_at(t, tol)?));
3985 }
3986 if *DEBUG_WIRE {
3987 eprintln!(
3988 "IMAGE FIT: image off the curve by {off:.2e} against {:.2e} (fit error {:.2e} met {})",
3989 budget.max(tol.confusion() * 1e3),
3990 fitted.error,
3991 fitted.met
3992 );
3993 }
3994 if off > budget.max(tol.confusion() * 1e3) {
3995 return Ok(None);
3996 }
3997 Ok(Some((planar, off)))
3998}
3999
4000/// The distance from a point to a bounded edge curve, through a sampling
4001/// fine enough for the along-boundary question it answers.
4002fn distance_to_edge_curve(
4003 curve: &Curve,
4004 crange: (f64, f64),
4005 p: Point,
4006 tol: Tolerances,
4007) -> OgeomResult<f64> {
4008 // Coarse bracket, then two rounds of local refinement: the answer feeds
4009 // the hug filters, whose widths are fractions of a millimetre, and a
4010 // long arc's 48-segment polyline sags by more than that on its own.
4011 let scan = |lo: f64, hi: f64, steps: u32| -> OgeomResult<(f64, f64)> {
4012 let mut best = f64::INFINITY;
4013 let mut best_t = lo;
4014 let mut previous: Option<(f64, Point)> = None;
4015 for i in 0..=steps {
4016 let t = lo + (hi - lo) * f64::from(i) / f64::from(steps);
4017 let at = curve.point_at(t, tol)?;
4018 if let Some((t0, last)) = previous {
4019 let d = at - last;
4020 let len2 = d.dot(d);
4021 let s = if len2 > 0.0 {
4022 ((p - last).dot(d) / len2).clamp(0.0, 1.0)
4023 } else {
4024 0.0
4025 };
4026 let dist = p.distance(last + d * s);
4027 if dist < best {
4028 best = dist;
4029 best_t = (t - t0).mul_add(s, t0);
4030 }
4031 }
4032 previous = Some((t, at));
4033 }
4034 Ok((best, best_t))
4035 };
4036 let span = crange.1 - crange.0;
4037 let (_, t1) = scan(crange.0, crange.1, 48)?;
4038 let step = span / 48.0;
4039 let (_, t2) = scan((t1 - step).max(crange.0), (t1 + step).min(crange.1), 16)?;
4040 let fine = span / (48.0 * 8.0);
4041 let (best, _) = scan((t2 - fine).max(crange.0), (t2 + fine).min(crange.1), 16)?;
4042 Ok(best)
4043}
4044
4045/// Pair up the pieces two coincident faces contribute for the same patch of
4046/// one surface, and mark the second argument's copy as stood in for.
4047///
4048/// The substitution is by *region*, not by argument: a piece of `b` is a
4049/// duplicate exactly where a piece of `a`, on the same side and on a surface
4050/// they share, already covers the point `b`'s piece was classified at. Where
4051/// `a` has nothing there (a bore refilled by the cylinder that cut it, whose
4052/// caps fill holes the part no longer has faces for), nothing stands in, and
4053/// `b`'s piece is the only description of that patch there is.
4054fn mark_covered_coincidences(ga: &GSolid, pieces: &mut [FacePiece], tol: Tolerances) {
4055 // Which pieces of the first argument stand on a shared surface.
4056 let from_a: Vec<(usize, usize)> = pieces
4057 .iter()
4058 .enumerate()
4059 .filter(|(_, p)| p.from_a && p.state == PieceState::OnAligned)
4060 .map(|(i, p)| (i, p.face))
4061 .collect();
4062 if from_a.is_empty() {
4063 return;
4064 }
4065 let mut covered: Vec<usize> = Vec::new();
4066 for (index, piece) in pieces.iter().enumerate() {
4067 if piece.from_a || piece.state != PieceState::OnAligned {
4068 continue;
4069 }
4070 for &(other, face_a) in &from_a {
4071 let host = &ga.faces[face_a];
4072 // The point `b`'s piece stands at, read in `a`'s face's chart. A
4073 // point that is not on that surface at all has no chart position,
4074 // and `chart_point_of` says so.
4075 let Some(at) = chart_point_of(host, piece.probe, tol) else {
4076 continue;
4077 };
4078 // The host piece's rings, not its boundary strands: a ring is a
4079 // closed loop whose last point does not repeat its first, so the
4080 // test has to close it. Asked as though the rings were strands
4081 // that jointly close, the segment from the ring's end back to its
4082 // start goes uncounted, and a point the ring plainly encloses
4083 // comes back outside whenever that missing segment would have
4084 // been crossed.
4085 if inside_rings(&pieces[other].outlines, at) {
4086 covered.push(index);
4087 break;
4088 }
4089 }
4090 }
4091 for index in covered {
4092 pieces[index].covered = true;
4093 }
4094}
4095
4096/// The debug dumps, read once rather than once per face.
4097///
4098/// `env::var` takes a process-wide lock and allocates; the strand dump asked
4099/// it inside the per-face loop, where a large model asks thousands of times
4100/// to be told no.
4101/// The face-bound filter's audit: admit every pair, and name any the filter
4102/// would have dropped that then produces a record. A conservative filter is
4103/// a correctness precondition (a pair wrongly dropped is absorbed by the
4104/// empty-result fallback today, and would be a wrong solid if that fallback
4105/// ever came up empty too), and this is the check that makes the
4106/// precondition falsifiable. Costs one branch per pair when off.
4107static AUDIT_BOUNDS: std::sync::LazyLock<bool> =
4108 std::sync::LazyLock::new(|| std::env::var("OGEOM_BOOL_AUDIT_BOUNDS").is_ok());
4109static DEBUG_WIRE: std::sync::LazyLock<bool> =
4110 std::sync::LazyLock::new(|| std::env::var("OGEOM_DEBUG_WIRE").is_ok());
4111static DEBUG_STRANDS: std::sync::LazyLock<bool> =
4112 std::sync::LazyLock::new(|| std::env::var("OGEOM_DEBUG_STRANDS").is_ok());
4113static ARRANGE_DEBUG: std::sync::LazyLock<bool> =
4114 std::sync::LazyLock::new(|| std::env::var("OGEOM_ARRANGE_DEBUG").is_ok());
4115
4116/// The strict audit's verdict: the filtered fill and the unfiltered fill
4117/// keep the same material.
4118///
4119/// Pieces are compared as merged parameter intervals per section: the
4120/// unfiltered run sees more curves, and a dropped pair's dead section can
4121/// still cross a live one and split its pieces differently, so individual
4122/// piece boundaries are noise and the kept *union* is the signal. Sections
4123/// are keyed by their face pair and a sampled point, because indices differ
4124/// between the runs.
4125fn audit_fill_equivalence(
4126 filtered: (&[SectionRec], &[SectionPiece]),
4127 unfiltered: (&[SectionRec], &[SectionPiece]),
4128 tol: Tolerances,
4129) {
4130 type Key = (usize, usize, [i64; 3]);
4131 let key_of = |section: &SectionRec, tol: Tolerances| -> Key {
4132 let (lo, hi) = section.curve.domain();
4133 let p = section
4134 .curve
4135 .point_at(f64::midpoint(lo, hi), tol)
4136 .unwrap_or(ogeom_math::Point::ORIGIN);
4137 let grid = (tol.confusion() * 100.0).max(1e-9);
4138 #[allow(clippy::cast_possible_truncation)]
4139 let q = |x: f64| (x / grid).round() as i64;
4140 (section.face_a, section.face_b, [q(p.x), q(p.y), q(p.z)])
4141 };
4142 let merged = |sections: &[SectionRec], pieces: &[SectionPiece], tol: Tolerances| {
4143 let mut kept: std::collections::HashMap<Key, Vec<(f64, f64)>> =
4144 std::collections::HashMap::new();
4145 for piece in pieces {
4146 kept.entry(key_of(§ions[piece.section], tol))
4147 .or_default()
4148 .push(piece.range);
4149 }
4150 let slop = tol.parametric() * 10.0;
4151 for ranges in kept.values_mut() {
4152 ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
4153 let mut out: Vec<(f64, f64)> = Vec::with_capacity(ranges.len());
4154 for &(lo, hi) in ranges.iter() {
4155 match out.last_mut() {
4156 Some(last) if lo <= last.1 + slop => last.1 = last.1.max(hi),
4157 _ => out.push((lo, hi)),
4158 }
4159 }
4160 *ranges = out;
4161 }
4162 kept
4163 };
4164 let a = merged(filtered.0, filtered.1, tol);
4165 let b = merged(unfiltered.0, unfiltered.1, tol);
4166 let slop = tol.parametric() * 20.0;
4167 let matches = |x: &Vec<(f64, f64)>, y: &Vec<(f64, f64)>| {
4168 x.len() == y.len()
4169 && x.iter()
4170 .zip(y)
4171 .all(|(p, q)| (p.0 - q.0).abs() <= slop && (p.1 - q.1).abs() <= slop)
4172 };
4173 for (key, ranges) in &b {
4174 let held = a.get(key);
4175 assert!(
4176 held.is_some_and(|r| matches(r, ranges)),
4177 "strict bound-filter audit: the unfiltered fill keeps {ranges:?} \
4178 on section {key:?}, the filtered fill keeps {held:?}; the \
4179 filter drops material"
4180 );
4181 }
4182 for (key, ranges) in &a {
4183 assert!(
4184 b.contains_key(key),
4185 "strict bound-filter audit: the filtered fill keeps {ranges:?} \
4186 on section {key:?} the unfiltered fill never made"
4187 );
4188 }
4189}
4190
4191fn general_fuse(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<GeneralFused> {
4192 ogeom_core::progress::stage("boolean: gather");
4193 let ga = gather(model, a, tol)?;
4194 let gb = gather(model, b, tol)?;
4195 ogeom_core::progress::stage("boolean: intersect");
4196 let (sections, section_pieces, contacts, tangents, contact_along, paves, same_a, same_b, hugs) =
4197 fill(&ga, &gb, false, tol)?;
4198 let mut junctions = pave_junctions(&ga, &gb, &paves, tol)?;
4199 if *DEBUG_WIRE {
4200 for j in &junctions {
4201 eprintln!("JUNCTION from paves at {:?} reach {:.3e}", j.at, j.reach);
4202 }
4203 for j in &hugs {
4204 eprintln!("JUNCTION from hugs at {:?} reach {:.3e}", j.at, j.reach);
4205 }
4206 }
4207 junctions.extend(hugs);
4208 for face in ga.faces.iter().chain(gb.faces.iter()) {
4209 // An input vertex that owns a span (the corner an earlier boolean
4210 // welded three rims into, each ending a fraction of a micron from
4211 // the others) is a junction here as well: the rebuild's positional
4212 // weld reaches only a few honesties, and minted separately those
4213 // ends stand as three vertices with hairlines between them.
4214 for e in &face.edges {
4215 for (at, radius) in e.ends {
4216 if radius > tol.confusion() * 10.0 {
4217 if *DEBUG_WIRE {
4218 eprintln!("JUNCTION from vertex at {at:?} radius {radius:.3e}");
4219 }
4220 junctions.push(Junction {
4221 at,
4222 reach: radius,
4223 onto_vertex: false,
4224 });
4225 }
4226 }
4227 }
4228 }
4229 // Junctions whose balls overlap are one junction: two pave clusters a
4230 // fraction of a micron apart at a corner where rims meet, each owning
4231 // a span that reaches into the other's, would weld a strand end to
4232 // whichever covers it first, and the corner would stand as two
4233 // vertices with a hairline between them, kept on one face, dropped
4234 // on another. Merged, the corner is one vertex whose reach covers
4235 // every member's span, on every face alike.
4236 junctions = merge_junctions(junctions);
4237 if *DEBUG_WIRE {
4238 for j in &junctions {
4239 eprintln!("JUNCTION at {:?} reach {:.3e}", j.at, j.reach);
4240 }
4241 let kind = |sf: &SurfaceGeometry| match sf {
4242 SurfaceGeometry::Plane(_) => "plane",
4243 SurfaceGeometry::Cylinder(_) => "cyl",
4244 SurfaceGeometry::BSpline(_) => "bspline",
4245 _ => "other",
4246 };
4247 for (si, sec) in sections.iter().enumerate() {
4248 let fa = &ga.faces[sec.face_a];
4249 let fb = &gb.faces[sec.face_b];
4250 let d = sec.curve.domain();
4251 let mut worst_a: f64 = 0.0;
4252 let mut worst_b: f64 = 0.0;
4253 let mut len = 0.0;
4254 let mut prev: Option<Point> = None;
4255 for i in 0..=32 {
4256 let t = if i == 32 {
4257 d.1
4258 } else {
4259 d.0 + (d.1 - d.0) * f64::from(i) / 32.0
4260 };
4261 let at = sec.curve.point_at(t, tol)?;
4262 if let Some(p) = prev {
4263 len += p.distance(at);
4264 }
4265 prev = Some(at);
4266 let near = |f: &GFace| -> OgeomResult<f64> {
4267 let mut best = f64::INFINITY;
4268 for e in &f.edges {
4269 best = best.min(distance_to_edge_curve(&e.curve, e.crange, at, tol)?);
4270 }
4271 Ok(best)
4272 };
4273 worst_a = worst_a.max(near(fa)?);
4274 worst_b = worst_b.max(near(fb)?);
4275 }
4276 let pieces = section_pieces.iter().filter(|p| p.section == si).count();
4277 eprintln!(
4278 "SECTION s{si} a{}({}) x b{}({}) tol {:.2e} closed {} len {:.4} pieces {pieces} hug-dist a {:.3e} b {:.3e}",
4279 sec.face_a,
4280 kind(&fa.surface),
4281 sec.face_b,
4282 kind(&fb.surface),
4283 sec.tolerance,
4284 sec.closed,
4285 len,
4286 worst_a,
4287 worst_b
4288 );
4289 }
4290 eprintln!("TANGENTS {} CONTACTS {}", tangents.len(), contacts.len());
4291 }
4292 // The strict audit: fill again with every pair admitted and demand the
4293 // same kept material. The production result above is always the
4294 // filtered run (under audit too), so the audit compares rather than
4295 // substitutes, and a filtered run *is* replayed exactly. Zero cost with
4296 // the variable unset.
4297 if *AUDIT_BOUNDS {
4298 let (audit_sections, audit_pieces, ..) = fill(&ga, &gb, true, tol)?;
4299 audit_fill_equivalence(
4300 (§ions, §ion_pieces),
4301 (&audit_sections, &audit_pieces),
4302 tol,
4303 );
4304 }
4305
4306 ogeom_core::progress::stage("boolean: split");
4307 let mut pieces: Vec<FacePiece> = Vec::new();
4308 // Every face's strands, before any face is arranged: the dust decision
4309 // (a piece shorter than its face's snap collapses to one node) must
4310 // be one decision per shared edge piece, and a sub-piece of one edge
4311 // lies in several charts, each with its own snap and its own metric.
4312 // A band's rail hugging a wedge's cylinder for a quarter of a micron
4313 // is dust in the wall's chart and a strand in the cylinder's, and the
4314 // sew then finds the cylinder's piece used once.
4315 let strands_of = |from_a: bool,
4316 fi: usize,
4317 face: &GFace|
4318 -> OgeomResult<(Vec<Strand<Tag>>, f64)> {
4319 let mut strands: Vec<Strand<Tag>> = Vec::new();
4320 // How far apart the paves one junction stands for lie. A section
4321 // meeting the edge all but tangentially (its fitted end sliding
4322 // along it) paves it a few microns from where an exact section
4323 // crosses; the first pave speaks for both, and the other's strand
4324 // must still reach it.
4325 let mut spread = 0.0_f64;
4326 for (ei, e) in face.edges.iter().enumerate() {
4327 let mut stops = vec![e.crange.0];
4328 if let Some(ts) = paves.get(&e.node) {
4329 // Paves the edge itself cannot tell apart are one
4330 // junction, and the cluster's first pave speaks for it.
4331 for c in cluster_paves(&e.curve, e.crange, e.tolerance, ts, tol)? {
4332 if c.members > 1 {
4333 spread = spread.max(c.span + c.honesty);
4334 }
4335 stops.push(c.t);
4336 }
4337 }
4338 stops.push(e.crange.1);
4339 // A closed boundary edge (a cap's full circle) needs two
4340 // distinct endpoints per strand.
4341 let closed_edge = e
4342 .curve
4343 .point_at(e.crange.0, tol)?
4344 .distance(e.curve.point_at(e.crange.1, tol)?)
4345 <= tol.confusion();
4346 if closed_edge && stops.len() == 2 {
4347 stops.insert(1, f64::midpoint(e.crange.0, e.crange.1));
4348 }
4349 for pair in stops.windows(2) {
4350 let sub = (pair[0], pair[1]);
4351 if sub.1 - sub.0 <= tol.parametric() {
4352 continue;
4353 }
4354 strands.push(Strand {
4355 polyline: pcurve_polyline(
4356 &e.pcurve,
4357 e.prange,
4358 e.crange,
4359 sub,
4360 &face.surface,
4361 tol,
4362 )?,
4363 tag: Tag::Boundary {
4364 edge: ei,
4365 range: sub,
4366 },
4367 boundary: true,
4368 });
4369 if let Some((other_pc, orange)) = &e.other_side {
4370 strands.push(Strand {
4371 polyline: pcurve_polyline(
4372 other_pc,
4373 *orange,
4374 e.crange,
4375 sub,
4376 &face.surface,
4377 tol,
4378 )?,
4379 tag: Tag::Boundary {
4380 edge: ei,
4381 range: sub,
4382 },
4383 boundary: true,
4384 });
4385 }
4386 }
4387 }
4388 for sp in §ion_pieces {
4389 let section = §ions[sp.section];
4390 let (belongs, pcurve) = if from_a {
4391 (section.face_a == fi && !sp.hugs[0], §ion.pc_a)
4392 } else {
4393 (section.face_b == fi && !sp.hugs[1], §ion.pc_b)
4394 };
4395 if !belongs {
4396 continue;
4397 }
4398 let domain = section.curve.domain();
4399 let sub = if section.closed {
4400 (
4401 fold(sp.range.0, domain),
4402 sp.range.1 - sp.range.0 + fold(sp.range.0, domain),
4403 )
4404 } else {
4405 sp.range
4406 };
4407 // The pcurve shares the curve's parameterization; sampling
4408 // uses folded parameters for periodic curves.
4409 let count = 32;
4410 let mut line = Vec::with_capacity(count + 1);
4411 for i in 0..=count {
4412 #[allow(clippy::cast_precision_loss)]
4413 let t = sub.0 + (sub.1 - sub.0) * i as f64 / count as f64;
4414 let tf = if section.closed { fold(t, domain) } else { t };
4415 line.push(pcurve.point_at(tf, tol)?);
4416 }
4417 // Folding the parameter can tear the sampled polyline at the
4418 // period; unwrap it pointwise, then bring the whole strand
4419 // into the chart with one shift.
4420 unwrap_polyline(&mut line, &face.surface);
4421 {
4422 let trim: Vec<&[Point2]> = strands
4423 .iter()
4424 .filter(|st| st.boundary)
4425 .map(|st| st.polyline.as_slice())
4426 .collect();
4427 fold_line_inside(&mut line, &face.surface, &trim);
4428 }
4429 strands.push(Strand {
4430 polyline: line,
4431 tag: Tag::Section {
4432 section: sp.section,
4433 range: sp.range,
4434 },
4435 boundary: false,
4436 });
4437 }
4438 // The poles, after the sections, because a section can end *on*
4439 // a pole (a plane through a ball's axis cuts it exactly there),
4440 // and the pole has to be cut where that happens or the two meet
4441 // at no shared node and the arrangement sees a dangling section.
4442 for (pi, pole) in face.poles.iter().enumerate() {
4443 let mut stops = vec![pole.prange.0, pole.prange.1];
4444 if let PlanarCurve::Line(line) = &pole.pcurve {
4445 let axis = line.axis();
4446 for strand in &strands {
4447 if strand.boundary {
4448 continue;
4449 }
4450 for end in [strand.polyline.first(), strand.polyline.last()]
4451 .into_iter()
4452 .flatten()
4453 {
4454 let along = (*end - axis.location).dot(axis.direction.vector());
4455 let foot = axis.point_at(along);
4456 if foot.distance(*end) <= PARAM_SNAP
4457 && along > pole.prange.0 + PARAM_SNAP
4458 && along < pole.prange.1 - PARAM_SNAP
4459 {
4460 stops.push(along);
4461 }
4462 }
4463 }
4464 }
4465 stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
4466 stops.dedup_by(|a, b| (*a - *b).abs() <= PARAM_SNAP);
4467 for pair in stops.windows(2) {
4468 let sub = (pair[0], pair[1]);
4469 strands.push(Strand {
4470 polyline: pcurve_polyline(
4471 &pole.pcurve,
4472 pole.prange,
4473 pole.prange,
4474 sub,
4475 &face.surface,
4476 tol,
4477 )?,
4478 tag: Tag::Pole {
4479 pole: pi,
4480 range: sub,
4481 },
4482 boundary: true,
4483 });
4484 }
4485 }
4486 for (ci, contact) in contacts.iter().enumerate() {
4487 if contact.target_from_a != from_a || contact.target_face != fi {
4488 continue;
4489 }
4490 // The owner's paves split its edge; the contact strands split
4491 // at the same parameters, so the sub-edges rebuilt from both
4492 // sides are the same edges and sew shared.
4493 let mut stops = vec![contact.crange.0];
4494 if let Some(ts) = paves.get(&contact.node) {
4495 stops.extend(
4496 cluster_paves(&contact.curve, contact.crange, contact.tolerance, ts, tol)?
4497 .iter()
4498 .map(|c| c.t),
4499 );
4500 }
4501 stops.push(contact.crange.1);
4502 let closed_contact = contact
4503 .curve
4504 .point_at(contact.crange.0, tol)?
4505 .distance(contact.curve.point_at(contact.crange.1, tol)?)
4506 <= tol.confusion();
4507 if closed_contact && stops.len() == 2 {
4508 stops.insert(1, f64::midpoint(contact.crange.0, contact.crange.1));
4509 }
4510 for pair in stops.windows(2) {
4511 let sub = (pair[0], pair[1]);
4512 if sub.1 - sub.0 <= tol.parametric() {
4513 continue;
4514 }
4515 let mid_t = f64::midpoint(sub.0, sub.1);
4516 if contact_along[ci]
4517 .iter()
4518 .any(|(lo, hi)| mid_t >= *lo && mid_t <= *hi)
4519 {
4520 // Already boundary on both sides.
4521 if *DEBUG_STRANDS {
4522 eprintln!("CONTACT c{ci} {sub:?} on fi={fi}: along boundary, skipped");
4523 }
4524 continue;
4525 }
4526 // Keep only what lies inside this face's trim; the rest
4527 // of the owner's boundary splits nothing here.
4528 let mut line = pcurve_polyline(
4529 &contact.pcurve,
4530 contact.prange,
4531 contact.crange,
4532 sub,
4533 &face.surface,
4534 tol,
4535 )?;
4536 unwrap_polyline(&mut line, &face.surface);
4537 let boundary_lines: Vec<&[Point2]> = strands
4538 .iter()
4539 .filter(|st| st.boundary)
4540 .map(|st| st.polyline.as_slice())
4541 .collect();
4542 fold_line_inside(&mut line, &face.surface, &boundary_lines);
4543 let mid = interior_of(&line);
4544 if !inside_many_slanted(&boundary_lines, mid) {
4545 if *DEBUG_STRANDS {
4546 eprintln!(
4547 "CONTACT c{ci} {sub:?} on fi={fi}: outside the trim at {mid:?}, skipped"
4548 );
4549 }
4550 continue;
4551 }
4552 strands.push(Strand {
4553 polyline: line,
4554 tag: Tag::Contact {
4555 contact: ci,
4556 range: sub,
4557 },
4558 boundary: false,
4559 });
4560 }
4561 }
4562
4563 // A tolerant contact's chart image meets the boundary it paved
4564 // only as closely as its own slop allows; the arrangement's node
4565 // weld reaches that far on this face, or the strand dangles a
4566 // few microns from the junction it belongs to.
4567 // A fitted section that hugs one of this face's edges leaves it
4568 // at the hug's far end by up to the hug's own width; its strand
4569 // must still find the edge's node there.
4570 let doubt_of = |edges: &[BoundaryEdge]| -> f64 {
4571 edges.iter().fold(0.0_f64, |acc, e| {
4572 acc.max(e.tolerance * 2.0)
4573 .max(e.ends_tolerance + e.tolerance)
4574 })
4575 };
4576 let near = contacts
4577 .iter()
4578 .filter(|c| c.target_from_a == from_a && c.target_face == fi)
4579 .fold(PARAM_SNAP, |acc, c| acc.max(c.tolerance * 2.0))
4580 // An edge's end lands in the chart within its vertex's own
4581 // recorded doubt plus its own image's: a projected pcurve is
4582 // honest to the edge's tolerance, and the vertex it ends at
4583 // was welded to some earlier gap.
4584 .max(doubt_of(&face.edges))
4585 .max(spread)
4586 .max(
4587 if sections.iter().any(|s| {
4588 s.tolerance > 0.0
4589 && if from_a {
4590 s.face_a == fi
4591 } else {
4592 s.face_b == fi
4593 }
4594 }) {
4595 tol.confusion() * 1e3
4596 } else {
4597 0.0
4598 },
4599 );
4600 // A section ends where it crosses the other face's boundary,
4601 // and lands there within that boundary's own doubt: two
4602 // sections through neighbouring facets of a converted mesh
4603 // stop on the edge they share a few microns apart, each on its
4604 // own facet's plane, and meet on this face only if the weld
4605 // reaches that far.
4606 let far = |section: usize| -> f64 {
4607 let s = §ions[section];
4608 doubt_of(if from_a {
4609 &gb.faces[s.face_b].edges
4610 } else {
4611 &ga.faces[s.face_a].edges
4612 })
4613 };
4614 let face_snap = near.max(
4615 (0..sections.len())
4616 .filter(|&k| {
4617 if from_a {
4618 sections[k].face_a == fi
4619 } else {
4620 sections[k].face_b == fi
4621 }
4622 })
4623 .map(far)
4624 .fold(0.0_f64, f64::max),
4625 );
4626 // The doubts above are lengths in space, and the weld runs in the
4627 // chart. Where the chart stretches every direction (a sphere of a
4628 // few millimetres runs a radian over its whole radius), a gap in
4629 // space is that many times narrower in the chart, and a weld taken
4630 // at the space length would swallow whole edges of a small patch.
4631 // Where some direction shrinks instead (a pole, a thin cylinder's
4632 // turn), the length stands.
4633 let stretch = strands
4634 .iter()
4635 .flat_map(|st| st.polyline.iter())
4636 .step_by(8)
4637 .filter_map(|p| face.surface.d1_at(p.x, p.y, tol).ok())
4638 .map(|(du, dv)| du.magnitude().min(dv.magnitude()))
4639 .fold(f64::INFINITY, f64::min);
4640 let face_snap = if stretch.is_finite() && stretch > 1.0 {
4641 (face_snap / stretch).max(PARAM_SNAP)
4642 } else {
4643 face_snap
4644 };
4645 // Where only one direction stretches (a cylinder's turn against its
4646 // straight length) no single scale holds, and a strand can be short
4647 // in the chart yet long in space. And the weld is the loosest doubt
4648 // of anything on the face: one sloppy edge a section crosses sets it
4649 // for every other section there. A strand longer in space than its
4650 // own doubt is no dust, so the weld stays below its chart length,
4651 // or it collapses and takes its neighbours' ends with it.
4652 let own_doubt = |tag: &Tag| -> f64 {
4653 match tag {
4654 Tag::Section { section, .. } => near.max(far(*section)),
4655 Tag::Pole { .. } => f64::INFINITY,
4656 Tag::Boundary { .. } | Tag::Contact { .. } => near,
4657 }
4658 };
4659 let face_snap = strands
4660 .iter()
4661 .filter(|st| {
4662 st.polyline.len() >= 2
4663 && st
4664 .polyline
4665 .windows(2)
4666 .map(|w| w[0].distance(w[1]))
4667 .sum::<f64>()
4668 <= face_snap
4669 })
4670 .filter_map(|st| {
4671 let points: Vec<Point> = st
4672 .polyline
4673 .iter()
4674 .filter_map(|p| face.surface.point_at(p.x, p.y, tol).ok())
4675 .collect();
4676 let space: f64 = points.windows(2).map(|w| w[0].distance(w[1])).sum();
4677 (space > own_doubt(&st.tag)).then(|| {
4678 st.polyline
4679 .windows(2)
4680 .map(|w| w[0].distance(w[1]))
4681 .sum::<f64>()
4682 / 2.0
4683 })
4684 })
4685 .fold(face_snap, f64::min)
4686 .max(PARAM_SNAP);
4687 // A section that runs along one of this face's edges into its end
4688 // stops within the hug's width of the node the edge was split at,
4689 // and two sections meeting a rail that grazes this face stop on it
4690 // as far apart as the rail stays within tolerance of the face. The
4691 // junction there says each pair is one point. Where a chart unit is
4692 // shorter than a millimetre, or the graze long, that gap is wider in
4693 // the chart than the weld, and the section dangles beside the node it
4694 // ends at. Its end moves onto a boundary node the junction holds, or
4695 // onto the first section end already there.
4696 let mut anchors: Vec<(Option<usize>, Point2, Point)> = strands
4697 .iter()
4698 .filter(|st| st.boundary && st.polyline.len() >= 2)
4699 .flat_map(|st| [st.polyline[0], st.polyline[st.polyline.len() - 1]])
4700 .filter_map(|p| {
4701 face.surface
4702 .point_at(p.x, p.y, tol)
4703 .ok()
4704 .map(|q| (None, p, q))
4705 })
4706 .collect();
4707 for st in strands
4708 .iter_mut()
4709 .filter(|st| matches!(st.tag, Tag::Section { .. }) && st.polyline.len() >= 2)
4710 {
4711 let last = st.polyline.len() - 1;
4712 for at in [0, last] {
4713 let p = st.polyline[at];
4714 if anchors
4715 .iter()
4716 .any(|(held, n, _)| held.is_none() && n.distance(p) <= face_snap)
4717 {
4718 continue;
4719 }
4720 let Ok(q) = face.surface.point_at(p.x, p.y, tol) else {
4721 continue;
4722 };
4723 let Some((ji, junction)) = junctions
4724 .iter()
4725 .enumerate()
4726 .find(|(_, j)| j.at.distance(q) <= j.reach)
4727 else {
4728 continue;
4729 };
4730 // The nearest in space the junction also holds, and reached
4731 // across the chart without leaving it: the chart's midpoint
4732 // of the two lies midway in space too, where a node in
4733 // another period's copy would not.
4734 let anchor = anchors
4735 .iter()
4736 .filter(|(held, _, s)| {
4737 held.is_none_or(|h| h == ji) && junction.at.distance(*s) <= junction.reach
4738 })
4739 .filter(|(_, n, s)| {
4740 face.surface
4741 .point_at((n.x + p.x) / 2.0, (n.y + p.y) / 2.0, tol)
4742 .is_ok_and(|m| m.distance(q.midpoint(*s)) <= junction.reach)
4743 })
4744 .min_by(|a, b| {
4745 a.2.distance(q)
4746 .partial_cmp(&b.2.distance(q))
4747 .unwrap_or(core::cmp::Ordering::Equal)
4748 })
4749 .map(|(_, n, _)| *n);
4750 match anchor {
4751 Some(n) => st.polyline[at] = n,
4752 None => anchors.push((Some(ji), p, q)),
4753 }
4754 }
4755 }
4756 if *DEBUG_STRANDS {
4757 eprintln!(
4758 "FACE-SNAP from_a={from_a} fi={fi}: snap {face_snap:.3e} edges {:?}",
4759 face.edges
4760 .iter()
4761 .map(|e| (
4762 format!("{:.2e}", e.tolerance),
4763 format!("{:.2e}", e.ends_tolerance)
4764 ))
4765 .collect::<Vec<_>>()
4766 );
4767 for (si, st) in strands.iter().enumerate() {
4768 let tag = match st.tag {
4769 Tag::Boundary { edge, range } => format!("Boundary e{edge} {range:?}"),
4770 Tag::Contact { contact, range } => format!("Contact c{contact} {range:?}"),
4771 Tag::Section { section, range } => format!("Section s{section} {range:?}"),
4772 Tag::Pole { pole, range } => format!("Pole p{pole} {range:?}"),
4773 };
4774 let (a, b) = (st.polyline[0], st.polyline[st.polyline.len() - 1]);
4775 eprintln!(
4776 "STRAND from_a={from_a} fi={fi} {si}: boundary={} {tag} pts={} {a:?} .. {b:?}",
4777 st.boundary,
4778 st.polyline.len()
4779 );
4780 }
4781 }
4782 Ok((strands, face_snap))
4783 };
4784 let chart_length =
4785 |line: &[Point2]| -> f64 { line.windows(2).map(|w| w[0].distance(w[1])).sum::<f64>() };
4786 // The identity of a piece across faces: a boundary or contact piece by
4787 // its edge node, a section piece by its section: the same section is
4788 // split the same way on both faces it cuts, and a piece that is dust
4789 // in a sphere's chart at the pole must be dust on the plane it also
4790 // lies in.
4791 let node_of = |face: &GFace, tag: &Tag| -> Option<(usize, usize, (f64, f64))> {
4792 match tag {
4793 Tag::Boundary { edge, range } => {
4794 Some((0, face.edges[*edge].node.index() as usize, *range))
4795 }
4796 Tag::Contact { contact, range } => {
4797 Some((1, contacts[*contact].node.index() as usize, *range))
4798 }
4799 Tag::Section { section, range } => Some((2, *section, *range)),
4800 Tag::Pole { .. } => None,
4801 }
4802 };
4803 /// A face's strands and its snap, waiting to be arranged.
4804 type Prepared = Option<(Vec<Strand<Tag>>, f64)>;
4805 let mut prepared: [Vec<Prepared>; 2] = [Vec::new(), Vec::new()];
4806 let mut dust: Vec<(usize, usize, (f64, f64))> = Vec::new();
4807 for (side, solid, from_a) in [(0_usize, &ga, true), (1, &gb, false)] {
4808 for (fi, face) in solid.faces.iter().enumerate() {
4809 ogeom_core::progress::checkpoint()?;
4810 let (strands, snap) = strands_of(from_a, fi, face)?;
4811 for st in &strands {
4812 if st.polyline.len() >= 2
4813 && chart_length(&st.polyline) <= snap
4814 && let Some(key) = node_of(face, &st.tag)
4815 {
4816 dust.push(key);
4817 }
4818 }
4819 prepared[side].push(Some((strands, snap)));
4820 }
4821 }
4822 ogeom_core::progress::stage("boolean: split arrange");
4823 let same_key = |a: &(usize, usize, (f64, f64)), b: &(usize, usize, (f64, f64))| {
4824 a.0 == b.0
4825 && a.1 == b.1
4826 && (a.2.0 - b.2.0).abs() <= tol.parametric()
4827 && (a.2.1 - b.2.1).abs() <= tol.parametric()
4828 };
4829 for (from_a, own, other) in [(true, &ga, &gb.solid), (false, &gb, &ga.solid)] {
4830 // The other solid's boundary, prepared once for the whole side. It is
4831 // asked once per face piece, and what it costs to prepare (every
4832 // face's trimming rings, polylined) does not depend on the point
4833 // being asked about. Rebuilt per question it dwarfed the question:
4834 // 3.5 ms of preparation against 5.6 µs of ray casting.
4835 let boundary = ogeom_algo::SolidBoundary::of(model, other, tol.confusion() * 1e4, tol)?;
4836 for (fi, face) in own.faces.iter().enumerate() {
4837 ogeom_core::progress::checkpoint()?;
4838 let Some((mut strands, face_snap)) = prepared[usize::from(!from_a)][fi].take() else {
4839 ogeom_bail!(Construction, "a face was prepared twice");
4840 };
4841 // A piece some other chart already collapsed collapses here too:
4842 // its ends become one node, every neighbour meeting them moves
4843 // onto it, and one junction owns the span in space so the
4844 // rebuilt vertices agree on every face.
4845 // A strand's ends in space, or none for a pole.
4846 let space_ends = |face: &GFace, tag: &Tag| -> OgeomResult<Option<(Point, Point)>> {
4847 Ok(Some(match tag {
4848 Tag::Boundary { edge, range } => {
4849 let e = &face.edges[*edge];
4850 (
4851 e.curve.point_at(range.0, tol)?,
4852 e.curve.point_at(range.1, tol)?,
4853 )
4854 }
4855 Tag::Contact { contact, range } => {
4856 let c = &contacts[*contact];
4857 (
4858 c.curve.point_at(range.0, tol)?,
4859 c.curve.point_at(range.1, tol)?,
4860 )
4861 }
4862 Tag::Section { section, range } => {
4863 let sec = §ions[*section];
4864 let domain = sec.curve.domain();
4865 (
4866 sec.curve
4867 .point_at(at_param(range.0, domain, sec.closed), tol)?,
4868 sec.curve
4869 .point_at(at_param(range.1, domain, sec.closed), tol)?,
4870 )
4871 }
4872 Tag::Pole { .. } => return Ok(None),
4873 }))
4874 };
4875 // A strand this face's own weld collapses is one point in space
4876 // as well: its ends become one junction, or the pieces either
4877 // side of it end on two vertices the chart says are one, and a
4878 // loop cut at a seam a few microns from its own start closes in
4879 // the chart and stays open in space.
4880 for st in &strands {
4881 if st.polyline.len() >= 2
4882 && chart_length(&st.polyline) <= face_snap
4883 && let Some((from, to)) = space_ends(face, &st.tag)?
4884 && from.distance(to) > tol.confusion() * 1e2
4885 {
4886 junctions.push(Junction {
4887 at: from.midpoint(to),
4888 reach: from.distance(to) / 2.0 + tol.confusion() * 1e2,
4889 onto_vertex: false,
4890 });
4891 }
4892 }
4893 let forced: Vec<usize> = strands
4894 .iter()
4895 .enumerate()
4896 .filter(|(_, st)| {
4897 st.polyline.len() >= 2
4898 && chart_length(&st.polyline) > face_snap
4899 && node_of(face, &st.tag)
4900 .is_some_and(|k| dust.iter().any(|d| same_key(d, &k)))
4901 })
4902 .map(|(i, _)| i)
4903 .collect();
4904 if !forced.is_empty() {
4905 let mut reps: Vec<(Point2, usize)> = Vec::new();
4906 let canon = |p: Point2, reps: &mut Vec<(Point2, usize)>| -> usize {
4907 match reps.iter().position(|(q, _)| q.distance(p) <= face_snap) {
4908 Some(i) => i,
4909 None => {
4910 reps.push((p, reps.len()));
4911 reps.len() - 1
4912 }
4913 }
4914 };
4915 fn root(reps: &mut [(Point2, usize)], mut i: usize) -> usize {
4916 while reps[i].1 != i {
4917 reps[i].1 = reps[reps[i].1].1;
4918 i = reps[i].1;
4919 }
4920 i
4921 }
4922 for &i in &forced {
4923 let st = &strands[i];
4924 let a = canon(st.polyline[0], &mut reps);
4925 let b = canon(st.polyline[st.polyline.len() - 1], &mut reps);
4926 let (ra, rb) = (root(&mut reps, a), root(&mut reps, b));
4927 if ra != rb {
4928 reps[rb].1 = ra;
4929 }
4930 let Some((from, to)) = space_ends(face, &st.tag)? else {
4931 continue;
4932 };
4933 if *DEBUG_STRANDS {
4934 eprintln!(
4935 "DUST from_a={from_a} fi={fi}: strand {i} collapses with its partners, {from:?} -> {to:?}"
4936 );
4937 }
4938 junctions.push(Junction {
4939 at: from.midpoint(to),
4940 reach: from.distance(to) / 2.0 + tol.confusion() * 1e2,
4941 onto_vertex: false,
4942 });
4943 }
4944 for (i, st) in strands.iter_mut().enumerate() {
4945 if forced.contains(&i) {
4946 continue;
4947 }
4948 let last = st.polyline.len() - 1;
4949 for end in [0, last] {
4950 if let Some(k) = reps
4951 .iter()
4952 .position(|(q, _)| q.distance(st.polyline[end]) <= face_snap)
4953 {
4954 let r = root(&mut reps, k);
4955 st.polyline[end] = reps[r].0;
4956 }
4957 }
4958 }
4959 let mut index = 0_usize;
4960 strands.retain(|_| {
4961 let keep = !forced.contains(&index);
4962 index += 1;
4963 keep
4964 });
4965 }
4966 let split = match arrange_pieces(&strands, face_snap) {
4967 Ok(split) => split,
4968 Err(err) => {
4969 if *ARRANGE_DEBUG {
4970 eprintln!("ARRANGE from_a={from_a} fi={fi} failed: {err}");
4971 for (si, st) in strands.iter().enumerate() {
4972 let (a, b) = (st.polyline[0], st.polyline[st.polyline.len() - 1]);
4973 let describe = |curve: &Curve, range: (f64, f64)| -> String {
4974 let mid = curve
4975 .point_at(f64::midpoint(range.0, range.1), tol)
4976 .map(|p| format!("({:.4},{:.4},{:.4})", p.x, p.y, p.z))
4977 .unwrap_or_default();
4978 match curve {
4979 Curve::Circle(c) => format!(
4980 "circle centre {:?} r {:.4} mid {mid}",
4981 c.circle().centre(),
4982 c.circle().radius()
4983 ),
4984 other => {
4985 format!("{:?} mid {mid}", core::mem::discriminant(other))
4986 }
4987 }
4988 };
4989 let geometry = match st.tag {
4990 Tag::Boundary { edge, range } => {
4991 describe(&face.edges[edge].curve, range)
4992 }
4993 Tag::Contact { contact, range } => {
4994 format!(
4995 "{} window {:?} along {:?} node {}",
4996 describe(&contacts[contact].curve, range),
4997 contacts[contact].crange,
4998 contact_along[contact],
4999 contacts[contact].node.index()
5000 )
5001 }
5002 _ => String::new(),
5003 };
5004 eprintln!(" {geometry}");
5005 eprintln!(
5006 " strand {si} boundary={} {:?} {a:?} .. {b:?}",
5007 st.boundary,
5008 match st.tag {
5009 Tag::Boundary { edge, range } =>
5010 format!("Boundary e{edge} {range:?}"),
5011 Tag::Contact { contact, range } =>
5012 format!("Contact c{contact} {range:?}"),
5013 Tag::Section { section, range } =>
5014 format!("Section s{section} {range:?}"),
5015 Tag::Pole { pole, range } => format!("Pole p{pole} {range:?}"),
5016 }
5017 );
5018 }
5019 }
5020 return Err(err);
5021 }
5022 };
5023 for piece in split {
5024 // Where a piece stands is asked at its interior probes in
5025 // turn. The first is the roomiest, and usually the only one
5026 // needed; the rest are for the piece that merely *touches*
5027 // the other solid, whose roomiest probe can land on the
5028 // contact and read neither in nor out.
5029 //
5030 // A partner face is asked before the classifier, not after.
5031 // Where a piece sits on a surface the other solid also
5032 // carries, the partner *is* the answer, and getting there
5033 // through the classifier means every ray grazing the shared
5034 // face, its whole fan of directions exhausted, before it
5035 // reports the On the partner list already knew. On a part
5036 // whose bore is refilled by its own cylinder that is the
5037 // difference between a tenth of a second and a minute.
5038 let partners = if from_a { &same_a[fi] } else { &same_b[fi] };
5039 let mut chosen = None;
5040 for candidate in &piece.interiors {
5041 let at = face.surface.point_at(candidate.x, candidate.y, tol)?;
5042 let shared = !partners.is_empty()
5043 && partners.iter().any(|&pi| {
5044 let partner = if from_a { &gb.faces[pi] } else { &ga.faces[pi] };
5045 chart_point_of(partner, at, tol).is_some()
5046 });
5047 let says = if shared {
5048 Containment::On
5049 } else {
5050 boundary.holds(model, at, tol)?
5051 };
5052 if chosen.is_none() || !matches!(says, Containment::On) {
5053 chosen = Some((*candidate, at, says));
5054 }
5055 if !matches!(says, Containment::On) {
5056 break;
5057 }
5058 }
5059 let Some((interior, probe, said)) = chosen else {
5060 ogeom_bail!(
5061 Construction,
5062 "a piece of a face has no interior point to classify at"
5063 );
5064 };
5065 let state = match said {
5066 Containment::In => PieceState::In,
5067 Containment::Out => PieceState::Out,
5068 Containment::On => {
5069 // On the other boundary: same-domain contact. The
5070 // partner face on the shared surface decides whether
5071 // the two materials lie on the same side or oppose.
5072 let own_normal = outward_normal(face, interior, tol)?;
5073 let mut resolved = None;
5074 for &pi in partners {
5075 let partner = if from_a { &gb.faces[pi] } else { &ga.faces[pi] };
5076 let Some(uv) = chart_point_of(partner, probe, tol) else {
5077 continue;
5078 };
5079 let theirs = outward_normal(partner, uv, tol)?;
5080 resolved = Some(if own_normal.dot(theirs) > 0.0 {
5081 PieceState::OnAligned
5082 } else {
5083 PieceState::OnOpposed
5084 });
5085 break;
5086 }
5087 let Some(state) = resolved else {
5088 // On with no partner containing the probe: the
5089 // band's generosity read proximity as
5090 // coincidence. Ask again at a width where it
5091 // cannot, and only a genuine edge contact
5092 // remains refused.
5093 match ogeom_algo::classify_in_solid_exact_banded(
5094 model,
5095 other,
5096 probe,
5097 tol.confusion() * 10.0,
5098 tol,
5099 )? {
5100 Containment::In => {
5101 pieces.push(FacePiece {
5102 from_a,
5103 face: fi,
5104 rings: piece.rings,
5105 outlines: piece.outlines,
5106 probe,
5107 state: PieceState::In,
5108 covered: false,
5109 });
5110 continue;
5111 }
5112 Containment::Out => {
5113 pieces.push(FacePiece {
5114 from_a,
5115 face: fi,
5116 rings: piece.rings,
5117 outlines: piece.outlines,
5118 probe,
5119 state: PieceState::Out,
5120 covered: false,
5121 });
5122 continue;
5123 }
5124 Containment::On => {}
5125 }
5126 if *DEBUG_WIRE {
5127 eprintln!(
5128 "ON-CONTACT piece of {} face {fi} probe {probe:?} partners {partners:?}",
5129 if from_a { "A" } else { "B" }
5130 );
5131 }
5132 ogeom_bail!(
5133 NotDone,
5134 "a piece lies on the other solid's boundary \
5135 with no coincident partner face to compare \
5136 sides against; edge or vertex contact is \
5137 refused rather than resolved; see the \
5138 remaining work in docs/PLAN.md"
5139 );
5140 };
5141 state
5142 }
5143 };
5144 pieces.push(FacePiece {
5145 from_a,
5146 face: fi,
5147 rings: piece.rings,
5148 outlines: piece.outlines,
5149 probe,
5150 state,
5151 covered: false,
5152 });
5153 }
5154 }
5155 }
5156 mark_covered_coincidences(&ga, &mut pieces, tol);
5157 if *ARRANGE_DEBUG {
5158 for (fi, partners) in same_a.iter().enumerate() {
5159 if !partners.is_empty() {
5160 eprintln!("SAME a{fi} with b{partners:?}");
5161 }
5162 }
5163 for (ci, contact) in contacts.iter().enumerate() {
5164 eprintln!(
5165 "CONTACT c{ci} onto {}{} over {:?} prange {:?} node {} pcurve {:?}",
5166 if contact.target_from_a { "a" } else { "b" },
5167 contact.target_face,
5168 contact.crange,
5169 contact.prange,
5170 contact.node.index(),
5171 contact.pcurve
5172 );
5173 }
5174 for (i, p) in pieces.iter().enumerate() {
5175 let own = if p.from_a {
5176 &ga.faces[p.face]
5177 } else {
5178 &gb.faces[p.face]
5179 };
5180 let kind = match &own.surface {
5181 SurfaceGeometry::Plane(_) => "plane",
5182 SurfaceGeometry::Cylinder(_) => "cyl",
5183 SurfaceGeometry::Torus(_) => "torus",
5184 SurfaceGeometry::BSpline(_) => "bspline",
5185 _ => "other",
5186 };
5187 eprintln!(
5188 "PIECE {i} from_a={} face={} {kind} state={:?} covered={} probe={:?} rings {:?}",
5189 p.from_a,
5190 p.face,
5191 p.state,
5192 p.covered,
5193 p.probe,
5194 p.rings.iter().map(Vec::len).collect::<Vec<_>>()
5195 );
5196 }
5197 }
5198 ogeom_core::progress::stage("boolean: classified");
5199 // Merged again: the arrangement adds junctions of its own (a strand
5200 // that collapsed with its partners becomes one) after the first
5201 // merge, and two of those at one triple point, each within the other's
5202 // reach, welded the ends of one band to two vertices with a hairline
5203 // between them.
5204 let junctions = merge_junctions(junctions);
5205 Ok(GeneralFused {
5206 a: ga,
5207 b: gb,
5208 sections,
5209 contacts,
5210 tangents,
5211 pieces,
5212 junctions,
5213 })
5214}
5215
5216// --- rebuilding --------------------------------------------------------------
5217
5218/// Everything the rebuild shares across pieces.
5219struct Rebuild<'m> {
5220 model: &'m mut Model,
5221 /// World surface ids, minted once per source face.
5222 surfaces_a: Vec<Option<ogeom_topo::SurfaceId>>,
5223 surfaces_b: Vec<Option<ogeom_topo::SurfaceId>>,
5224 /// Vertices shared by position: a wire's connectivity is checked by node
5225 /// identity, so two sub-edges meeting at a point must *name* the same
5226 /// vertex, not merely coincide there.
5227 vertices: Vec<(Point, Shape)>,
5228 /// `vertices` binned by position.
5229 vertex_bins: bins::Bins,
5230 /// The widest tolerance any vertex in `vertices` holds: every widening
5231 /// of one goes through [`Rebuild::vertex`] or [`Rebuild::widen`], so an
5232 /// end is compared only with the vertices this far and a weld from it.
5233 widest: f64,
5234 /// How far two honest descriptions of one junction may sit apart: a
5235 /// hundred confusions as the floor, widened to three times the loosest
5236 /// contact edge's or fitted section's own tolerance when one took part:
5237 /// the strand's own, the crossing's, and the edge's it stopped on.
5238 weld: f64,
5239 /// The paving's junctions, each minted as a vertex the first time a
5240 /// strand end lands inside it.
5241 junctions: Vec<(Junction, Option<Shape>)>,
5242 /// `junctions` binned by position.
5243 junction_bins: bins::Bins,
5244 /// The widest reach of any junction.
5245 junction_reach: f64,
5246 /// Vertices minted from junctions at an edge's own end vertex, which a
5247 /// tangent section's chart image is bent onto.
5248 onto_vertex: std::collections::HashSet<ogeom_topo::TShapeId>,
5249}
5250
5251impl Rebuild<'_> {
5252 fn vertex(&mut self, p: Point, tol: Tolerances) -> Shape {
5253 // A junction several paves described is one vertex owning the span
5254 // they disagree by, and every strand end inside it names that
5255 // vertex, whichever pave its own trim stopped at.
5256 // The rim is as generous as the positional weld below: two strand
5257 // ends a weld apart must not fall on opposite sides of it.
5258 let slack = self.weld.max(tol.confusion() * 1e2);
5259 if *DEBUG_WIRE {
5260 eprintln!("VERTEX ask {p:?}");
5261 }
5262 let reach = self.junction_reach;
5263 let inside = |slot: &usize| {
5264 let j = &self.junctions[*slot].0;
5265 j.at.distance(p) <= j.reach + slack
5266 };
5267 let first = match self.junction_bins.near(p, reach + slack) {
5268 Some(near) => near.into_iter().find(inside),
5269 None => (0..self.junctions.len()).find(inside),
5270 };
5271 if let Some(slot) = first {
5272 let junction = self.junctions[slot].0;
5273 let shape = match &self.junctions[slot].1 {
5274 Some(shape) => shape.clone(),
5275 None => {
5276 let shape = make_vertex(self.model, junction.at).shape;
5277 self.junctions[slot].1 = Some(shape.clone());
5278 self.remember(junction.at, &shape);
5279 shape
5280 }
5281 };
5282 if junction.onto_vertex {
5283 self.onto_vertex.insert(shape.node());
5284 }
5285 // The junction owns its span, and an end welded in from the rim
5286 // widens it by what it actually sat off by, as any weld does.
5287 let gap = junction.at.distance(p);
5288 if let Some(node) = self.model.node_mut(&shape)
5289 && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
5290 {
5291 // A confusion of slack past the measured gap: an edge built
5292 // against this vertex measures the same gap through its own
5293 // rounding, and a tolerance equal to it fails by an ulp.
5294 data.tolerance = data
5295 .tolerance
5296 .widen_to(junction.reach.max(gap) + tol.confusion());
5297 }
5298 self.note_tolerance(&shape);
5299 return shape;
5300 }
5301 // The weld reach covers what the inputs may honestly disagree by: a
5302 // boundary curve that is itself a fitted intersection from an
5303 // earlier boolean carries a couple of microns of slop, and two
5304 // descriptions of one junction arrive that far apart. A hundred
5305 // confusions at millimetre tolerances is ten microns, below any
5306 // feature this pipeline can resolve, and every weld wider than
5307 // confusion is recorded on the vertex, not papered over.
5308 // And as far again as the vertex already owns: a vertex widened by
5309 // the descriptions it has taken in claims its point within that
5310 // tolerance, an end arriving within that and the weld of it may be
5311 // the same point, and a boolean cannot show it is not. Three
5312 // descriptions of one triple point, where a third band met two
5313 // bands at an apex, arrived a tenth of a micron apart in turn, each
5314 // within the last's reach and none within the first's, and the
5315 // wire round the band's end had two vertices where it needed one.
5316 // Every end taken in is remembered where it arrived, so the next
5317 // end is measured from the description nearest it.
5318 let floor = self.weld.max(tol.confusion() * 1e2);
5319 let near = self
5320 .vertex_bins
5321 .near(p, floor + self.widest)
5322 .unwrap_or_else(|| (0..self.vertices.len()).collect());
5323 let found = near
5324 .into_iter()
5325 .map(|index| &self.vertices[index])
5326 .filter_map(|(q, shape)| {
5327 let own = self
5328 .model
5329 .node(shape)
5330 .and_then(|n| n.data().as_vertex())
5331 .map_or(0.0, |d| d.tolerance.get());
5332 let gap = q.distance(p);
5333 (gap <= floor.max(own + floor)).then_some((gap, shape.clone()))
5334 })
5335 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
5336 if let Some((gap, shape)) = found {
5337 // Two descriptions of one junction may disagree by a general
5338 // crossing's residual; the vertex's tolerance is where that
5339 // disagreement is recorded, so the sub-edges built against
5340 // either description still reach it honestly.
5341 let at = self
5342 .model
5343 .node(&shape)
5344 .and_then(|n| n.data().as_vertex())
5345 .map_or(p, |d| d.point);
5346 let off = at.distance(p);
5347 if off > tol.confusion()
5348 && let Some(node) = self.model.node_mut(&shape)
5349 && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
5350 {
5351 data.tolerance = data.tolerance.widen_to(off + tol.confusion());
5352 }
5353 self.note_tolerance(&shape);
5354 if gap > tol.confusion() {
5355 self.remember(p, &shape);
5356 }
5357 return shape;
5358 }
5359 let shape = make_vertex(self.model, p).shape;
5360 self.remember(p, &shape);
5361 shape
5362 }
5363
5364 /// Take in `shape` as described at `p`.
5365 fn remember(&mut self, p: Point, shape: &Shape) {
5366 self.vertex_bins.insert(p, self.vertices.len());
5367 self.vertices.push((p, shape.clone()));
5368 self.note_tolerance(shape);
5369 }
5370
5371 /// Keep `widest` covering `shape`'s tolerance.
5372 fn note_tolerance(&mut self, shape: &Shape) {
5373 if let Some(data) = self.model.node(shape).and_then(|n| n.data().as_vertex()) {
5374 self.widest = self.widest.max(data.tolerance.get());
5375 }
5376 }
5377
5378 /// Widen a vertex this rebuild handed out.
5379 fn widen(&mut self, vertex: &Shape, to: f64) -> OgeomResult<()> {
5380 self.model.widen(vertex, ogeom_core::Tolerance::new(to)?)?;
5381 self.note_tolerance(vertex);
5382 Ok(())
5383 }
5384
5385 fn surface_id(
5386 &mut self,
5387 fused: &GeneralFused,
5388 from_a: bool,
5389 face: usize,
5390 ) -> ogeom_topo::SurfaceId {
5391 let slot = if from_a {
5392 &mut self.surfaces_a[face]
5393 } else {
5394 &mut self.surfaces_b[face]
5395 };
5396 if let Some(id) = slot {
5397 return *id;
5398 }
5399 let surface = if from_a {
5400 fused.a.faces[face].surface.clone()
5401 } else {
5402 fused.b.faces[face].surface.clone()
5403 };
5404 let id = self.model.geometry_mut().add_surface(surface);
5405 *slot = Some(id);
5406 id
5407 }
5408}
5409
5410/// Build one piece as a face, orientation matching its source face's side.
5411fn build_piece(
5412 rebuild: &mut Rebuild,
5413 fused: &GeneralFused,
5414 piece: &FacePiece,
5415 tol: Tolerances,
5416) -> OgeomResult<Shape> {
5417 let own = if piece.from_a { &fused.a } else { &fused.b };
5418 let face = &own.faces[piece.face];
5419 let surface_id = rebuild.surface_id(fused, piece.from_a, piece.face);
5420
5421 // Sub-edges cached within the piece, so a seam used from both sides is
5422 // one edge appearing twice.
5423 let mut cache: Vec<(usize, u8, (f64, f64), Shape)> = Vec::new();
5424 let mut wires = Vec::new();
5425 for ring in &piece.rings {
5426 let mut edges = Vec::with_capacity(ring.len());
5427 for traversal in ring {
5428 let (key_edge, key_kind, range) = match &traversal.tag {
5429 Tag::Boundary { edge, range } => (*edge, 0_u8, *range),
5430 Tag::Section { section, range } => (*section, 1, *range),
5431 Tag::Contact { contact, range } => (*contact, 2, *range),
5432 Tag::Pole { pole, range } => (*pole, 3, *range),
5433 };
5434 let near = |a: (f64, f64), b: (f64, f64)| {
5435 (a.0 - b.0).abs() <= tol.parametric() && (a.1 - b.1).abs() <= tol.parametric()
5436 };
5437 if *DEBUG_WIRE && (range.1 - range.0).abs() < 1e-4 {
5438 let kind = match &traversal.tag {
5439 Tag::Boundary { .. } => "boundary",
5440 Tag::Section { .. } => "section",
5441 Tag::Contact { .. } => "contact",
5442 Tag::Pole { .. } => "pole",
5443 };
5444 eprintln!(
5445 "DUST {kind} {key_edge} range {range:?} in piece from_a={} face={}",
5446 piece.from_a, piece.face
5447 );
5448 }
5449 let built = if let Some((.., shape)) = cache
5450 .iter()
5451 .find(|(k, s, r, _)| *k == key_edge && *s == key_kind && near(*r, range))
5452 {
5453 shape.clone()
5454 } else {
5455 let shape = build_sub_edge(
5456 rebuild,
5457 fused,
5458 piece.from_a,
5459 face,
5460 surface_id,
5461 &traversal.tag,
5462 tol,
5463 )?;
5464 cache.push((key_edge, key_kind, range, shape.clone()));
5465 shape
5466 };
5467 // A piece whose two ends welded to one vertex and whose whole
5468 // length lies within the vertex's reach is that vertex's dust
5469 // (a rim's last fraction of a micron before a pole corner) and
5470 // no edge of the wire; a closed edge on one vertex, a full
5471 // circle, reaches far from it and stays.
5472 if !matches!(traversal.tag, Tag::Pole { .. })
5473 && let Some((v0, v1)) = ogeom_algo::edge_vertices(rebuild.model, &built)?
5474 && v0.node() == v1.node()
5475 && let Some(vd) = rebuild
5476 .model
5477 .node(&v0)
5478 .and_then(|n| n.data().as_vertex())
5479 .map(|d| (d.point, d.tolerance.get()))
5480 && let Some(ed) = rebuild.model.node(&built).and_then(|n| n.data().as_edge())
5481 && let Some(ogeom_topo::EdgeRepr::Curve3d {
5482 curve, range: r3, ..
5483 }) = ed.curve3d()
5484 && let Some(g) = rebuild.model.geometry().curve(*curve)
5485 && g.point_at(f64::midpoint(r3.0, r3.1), tol)?.distance(vd.0)
5486 <= vd.1.max(tol.confusion() * 1e4)
5487 {
5488 if *DEBUG_WIRE {
5489 eprintln!(
5490 "DUST piece closing on one vertex at {:?} dropped from piece from_a={} face={}",
5491 vd.0, piece.from_a, piece.face
5492 );
5493 }
5494 continue;
5495 }
5496 edges.push(if traversal.reversed {
5497 built.reversed()
5498 } else {
5499 built
5500 });
5501 }
5502 let wire = match make_wire(rebuild.model, &edges, tol) {
5503 Ok(w) => w.shape,
5504 Err(e) => {
5505 if *DEBUG_WIRE {
5506 eprintln!(
5507 "WIRE FAIL piece from_a={} face={}",
5508 piece.from_a, piece.face
5509 );
5510 for built in &edges {
5511 if let Some((a, b)) = ogeom_algo::edge_vertices(rebuild.model, built)? {
5512 let at = |v: &Shape| {
5513 rebuild
5514 .model
5515 .node(v)
5516 .and_then(|n| n.data().as_vertex())
5517 .map(|d| (d.point, d.tolerance.get()))
5518 };
5519 eprintln!(
5520 " built {:?}{}: v{} {:?} -> v{} {:?}",
5521 built.node(),
5522 if built.orientation() == ogeom_topo::Orientation::Reversed {
5523 " rev"
5524 } else {
5525 ""
5526 },
5527 a.node().index(),
5528 at(&a),
5529 b.node().index(),
5530 at(&b)
5531 );
5532 }
5533 }
5534 for t in ring {
5535 let tag = match &t.tag {
5536 Tag::Boundary { edge, range } => format!(
5537 "Boundary e{edge} {range:?} tol {:.2e}",
5538 face.edges[*edge].tolerance
5539 ),
5540 Tag::Contact { contact, range } => format!(
5541 "Contact c{contact} {range:?} tol {:.2e}",
5542 fused.contacts[*contact].tolerance
5543 ),
5544 Tag::Section { section, range } => format!(
5545 "Section s{section} {range:?} tol {:.2e}",
5546 fused.sections[*section].tolerance
5547 ),
5548 Tag::Pole { pole, range } => format!("Pole p{pole} {range:?}"),
5549 };
5550 eprintln!(" {tag} reversed={}", t.reversed);
5551 }
5552 }
5553 return Err(e);
5554 }
5555 };
5556 wires.push(wire);
5557 }
5558 let built = match make_face_on(rebuild.model, surface_id, &wires, tol) {
5559 Ok(b) => b.shape,
5560 Err(e) => {
5561 if *DEBUG_WIRE {
5562 eprintln!(
5563 "FACE FAIL piece from_a={} face={}: {e}",
5564 piece.from_a, piece.face
5565 );
5566 for (wi, wire) in wires.iter().enumerate() {
5567 for edge in rebuild.model.ordered_children_of(wire)? {
5568 if let Some((a, b)) = ogeom_algo::edge_vertices(rebuild.model, &edge)? {
5569 let at = |v: &Shape| {
5570 rebuild
5571 .model
5572 .node(v)
5573 .and_then(|n| n.data().as_vertex())
5574 .map(|d| d.point)
5575 };
5576 eprintln!(
5577 " wire {wi} edge {:?}{}: v{} {:?} -> v{} {:?}",
5578 edge.node(),
5579 if edge.orientation() == ogeom_topo::Orientation::Reversed {
5580 " rev"
5581 } else {
5582 ""
5583 },
5584 a.node().index(),
5585 at(&a),
5586 b.node().index(),
5587 at(&b)
5588 );
5589 }
5590 }
5591 }
5592 }
5593 return Err(e);
5594 }
5595 };
5596 Ok(
5597 if face.face.orientation() == ogeom_topo::Orientation::Reversed {
5598 built.reversed()
5599 } else {
5600 built
5601 },
5602 )
5603}
5604
5605/// Build the exact sub-edge a tag names, pcurves attached.
5606fn build_sub_edge(
5607 rebuild: &mut Rebuild,
5608 fused: &GeneralFused,
5609 from_a: bool,
5610 face: &GFace,
5611 surface_id: ogeom_topo::SurfaceId,
5612 tag: &Tag,
5613 tol: Tolerances,
5614) -> OgeomResult<Shape> {
5615 match tag {
5616 Tag::Pole { pole, range } => {
5617 // A pole rebuilds as what it was: one vertex, an edge with no
5618 // curve bounded by it twice, and the chart line that says where
5619 // it runs in this face's parameters.
5620 let p = &face.poles[*pole];
5621 let at = rebuild.vertex(p.point, tol);
5622 let model = &mut *rebuild.model;
5623 let mut data = ogeom_topo::EdgeData::new();
5624 data.degenerate = true;
5625 let built = model.add_edge(data, &[at.clone(), at])?;
5626 ogeom_algo::attach_pcurve(
5627 model,
5628 &built,
5629 p.pcurve.clone(),
5630 surface_id,
5631 Location::identity(),
5632 *range,
5633 )?;
5634 Ok(built)
5635 }
5636 Tag::Boundary { edge, range } => {
5637 let e = &face.edges[*edge];
5638 let from = e.curve.point_at(range.0, tol)?;
5639 let to = e.curve.point_at(range.1, tol)?;
5640 let v0 = rebuild.vertex(from, tol);
5641 let v1 = rebuild.vertex(to, tol);
5642 let model = &mut *rebuild.model;
5643 let built = make_edge_between(model, e.curve.clone(), *range, &v0, &v1, tol)?.shape;
5644 // A piece of a tolerant edge is the same curve with the same
5645 // honest radius: a fitted rail's stated slop must survive the
5646 // split, or the next boolean over this solid measures the rail
5647 // against a tolerance it never had. The ends own it too: a
5648 // junction on a tolerant curve is a junction to the curve's own
5649 // resolution, and the wires rebuilt through it meet within that.
5650 if e.tolerance > tol.confusion() {
5651 if let Some(node) = model.node_mut(&built)
5652 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
5653 {
5654 data.tolerance = data.tolerance.widen_to(e.tolerance);
5655 }
5656 for v in [&v0, &v1] {
5657 rebuild.widen(v, e.tolerance)?;
5658 }
5659 }
5660 let model = &mut *rebuild.model;
5661 let sub_p = (
5662 rescale(range.0, e.crange, e.prange),
5663 rescale(range.1, e.crange, e.prange),
5664 );
5665 match &e.other_side {
5666 None => ogeom_algo::attach_pcurve(
5667 model,
5668 &built,
5669 e.pcurve.clone(),
5670 surface_id,
5671 Location::identity(),
5672 sub_p,
5673 )?,
5674 Some((other, orange)) => {
5675 // A seam: both sides attach, and an occurrence picks its
5676 // side by its orientation.
5677 let sub_o = (
5678 rescale(range.0, e.crange, *orange),
5679 rescale(range.1, e.crange, *orange),
5680 );
5681 let _ = sub_o;
5682 ogeom_algo::attach_seam(
5683 model,
5684 &built,
5685 e.pcurve.clone(),
5686 other.clone(),
5687 surface_id,
5688 Location::identity(),
5689 sub_p,
5690 )?;
5691 }
5692 }
5693 Ok(built)
5694 }
5695 Tag::Contact { contact, range } => {
5696 let c = &fused.contacts[*contact];
5697 let from = c.curve.point_at(range.0, tol)?;
5698 let to = c.curve.point_at(range.1, tol)?;
5699 let v0 = rebuild.vertex(from, tol);
5700 let v1 = rebuild.vertex(to, tol);
5701 let model = &mut *rebuild.model;
5702 let built = make_edge_between(model, c.curve.clone(), *range, &v0, &v1, tol)?.shape;
5703 // A tolerant contact's pieces and ends own its stated slop, as a
5704 // boundary's and a section's do: three kinds of strand close one
5705 // ring, and every junction meets at the strands' own honesty.
5706 if c.tolerance > tol.confusion() {
5707 if let Some(node) = model.node_mut(&built)
5708 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
5709 {
5710 data.tolerance = data.tolerance.widen_to(c.tolerance);
5711 }
5712 for v in [&v0, &v1] {
5713 rebuild.widen(v, c.tolerance)?;
5714 }
5715 }
5716 let model = &mut *rebuild.model;
5717 // The stored image keeps its own window; the attached copy names
5718 // the sub-window this piece covers under the proportional map.
5719 let sub_p = (
5720 rescale(range.0, c.crange, c.prange),
5721 rescale(range.1, c.crange, c.prange),
5722 );
5723 let mid = c.pcurve.point_at(f64::midpoint(sub_p.0, sub_p.1), tol)?;
5724 let trim = face_trim_lines(face, tol);
5725 let trim: Vec<&[Point2]> = trim.iter().map(Vec::as_slice).collect();
5726 let folded = fold_inside(mid, &face.surface, &trim);
5727 let shifted = c
5728 .pcurve
5729 .transformed(&ogeom_math::Transform2::translation(folded - mid), tol)?;
5730 ogeom_algo::attach_pcurve(
5731 model,
5732 &built,
5733 shifted,
5734 surface_id,
5735 Location::identity(),
5736 sub_p,
5737 )?;
5738 Ok(built)
5739 }
5740 Tag::Section { section, range } => {
5741 let s = &fused.sections[*section];
5742 let domain = s.curve.domain();
5743 let (f0, f1) = folded_range(*range, domain, s.closed);
5744 let from = s.curve.point_at(at_param(f0, domain, s.closed), tol)?;
5745 let to = s.curve.point_at(at_param(f1, domain, s.closed), tol)?;
5746 let v0 = rebuild.vertex(from, tol);
5747 let v1 = rebuild.vertex(to, tol);
5748 let model = &mut *rebuild.model;
5749 let built = make_edge_between(model, s.curve.clone(), (f0, f1), &v0, &v1, tol)?.shape;
5750 // A piece welded into a junction some way off its own end (a
5751 // section tangent to an edge at the edge's vertex, stopped
5752 // where it began to hug the edge) owns that gap as the vertex
5753 // does: the face it bounds meets its neighbour there only that
5754 // closely, in its chart as in space.
5755 let at = |v: &Shape| {
5756 model
5757 .node(v)
5758 .and_then(|n| n.data().as_vertex())
5759 .map(|d| d.point)
5760 };
5761 let ends = [at(&v0), at(&v1)];
5762 let welded = [(ends[0], from), (ends[1], to)]
5763 .iter()
5764 .filter_map(|(v, p)| v.map(|v| v.distance(*p)))
5765 .fold(0.0, f64::max);
5766 let own = s.tolerance.max(welded);
5767 // A fitted section's pieces and their ends own the section's
5768 // stated slop, exactly as a tolerant boundary's do: the ring
5769 // they close alternates between the two, and both sides must
5770 // meet at the junction's own resolution.
5771 if own > tol.confusion() {
5772 if let Some(node) = model.node_mut(&built)
5773 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
5774 {
5775 data.tolerance = data.tolerance.widen_to(own);
5776 }
5777 for v in [&v0, &v1] {
5778 rebuild.widen(v, own)?;
5779 }
5780 }
5781 let model = &mut *rebuild.model;
5782 // The section's pcurve is unwrapped across any seam; the face's
5783 // triangulator lives in one chart, so the attached copy is folded
5784 // home by the same period shift the arrangement gave this
5785 // strand's polyline (to the side of the face's seam its trim is
5786 // on), decided by the sub-range's midpoint, so an
5787 // endpoint sitting exactly on the chart's edge stays on the side
5788 // the arc's body is.
5789 let pcurve = if from_a { &s.pc_a } else { &s.pc_b };
5790 let mid = pcurve.point_at(f64::midpoint(f0, f1), tol)?;
5791 let trim = face_trim_lines(face, tol);
5792 let trim: Vec<&[Point2]> = trim.iter().map(Vec::as_slice).collect();
5793 let folded = fold_inside(mid, &face.surface, &trim);
5794 let shifted =
5795 pcurve.transformed(&ogeom_math::Transform2::translation(folded - mid), tol)?;
5796 // The chart image ends where the vertex is, as the edge does in
5797 // space to its tolerance: a welded end left where the section
5798 // stopped leaves the face's outline open by the weld in its
5799 // chart, wider there than any later arrangement's snap.
5800 let loose = s.tolerance.max(tol.confusion() * 1e2);
5801 let bent = [
5802 rebuild.onto_vertex.contains(&v0.node()),
5803 rebuild.onto_vertex.contains(&v1.node()),
5804 ];
5805 let targets = [
5806 ends[0].filter(|v| bent[0] && v.distance(from) > loose),
5807 ends[1].filter(|v| bent[1] && v.distance(to) > loose),
5808 ];
5809 let shifted = if targets.iter().any(Option::is_some) {
5810 pcurve_onto_ends(&shifted, (f0, f1), &face.surface, targets, tol)?
5811 } else {
5812 shifted
5813 };
5814 ogeom_algo::attach_pcurve(
5815 model,
5816 &built,
5817 shifted,
5818 surface_id,
5819 Location::identity(),
5820 (f0, f1),
5821 )?;
5822 Ok(built)
5823 }
5824 }
5825}
5826
5827/// A pcurve over `range` bent at its ends onto the chart points of
5828/// `targets`, where given: each end's correction fades linearly to nothing
5829/// at the other end, and the result is refitted at the same parameters, so
5830/// the image stays same-parameter with its edge to the correction's size.
5831fn pcurve_onto_ends(
5832 pcurve: &PlanarCurve,
5833 range: (f64, f64),
5834 surface: &SurfaceGeometry,
5835 targets: [Option<Point>; 2],
5836 tol: Tolerances,
5837) -> OgeomResult<PlanarCurve> {
5838 const SAMPLES: u32 = 32;
5839 let ts: Vec<f64> = (0..=SAMPLES)
5840 .map(|i| range.0 + (range.1 - range.0) * f64::from(i) / f64::from(SAMPLES))
5841 .collect();
5842 let mut points = ts
5843 .iter()
5844 .map(|t| pcurve.point_at(*t, tol))
5845 .collect::<OgeomResult<Vec<Point2>>>()?;
5846 let last = points.len() - 1;
5847 for (k, target) in targets.iter().enumerate() {
5848 let Some(target) = target else {
5849 continue;
5850 };
5851 let end = points[if k == 0 { 0 } else { last }];
5852 let foot = ogeom_algo::project_on_surface_from(surface, *target, (end.x, end.y), tol)?;
5853 let raw = Point2::new(foot.parameters.0, foot.parameters.1);
5854 // The foot may land a turn away from the end on a periodic chart,
5855 // whether or not the surface's wrapper says it is periodic; a
5856 // shifted candidate counts where the surface agrees it is the foot.
5857 let turn = core::f64::consts::TAU;
5858 let mut shifts = period_shifts(surface);
5859 for du in [-turn, 0.0, turn] {
5860 for dv in [-turn, 0.0, turn] {
5861 shifts.push((du, dv));
5862 }
5863 }
5864 let onto = shifts
5865 .into_iter()
5866 .map(|(du, dv)| Point2::new(raw.x + du, raw.y + dv))
5867 .filter(|c| {
5868 surface
5869 .point_at(c.x, c.y, tol)
5870 .is_ok_and(|q| q.distance(foot.point) <= tol.confusion() * 1e2)
5871 })
5872 .min_by(|a, b| {
5873 a.distance(end)
5874 .partial_cmp(&b.distance(end))
5875 .unwrap_or(core::cmp::Ordering::Equal)
5876 })
5877 .unwrap_or(raw);
5878 let delta = onto - end;
5879 for (i, p) in points.iter_mut().enumerate() {
5880 #[allow(clippy::cast_precision_loss)]
5881 let along = i as f64 / last as f64;
5882 let weight = if k == 0 { 1.0 - along } else { along };
5883 *p += delta * weight;
5884 }
5885 }
5886 let fitted = ogeom_geom::fit::fit_points_2d_at(&ts, &points, 3, tol.confusion() * 10.0, tol)?;
5887 Ok(PlanarCurve::BSpline(fitted.curve))
5888}
5889
5890/// Sew kept pieces, demand closure, and nest shells into solids and voids.
5891fn assemble_result(
5892 model: &mut Model,
5893 fused: &GeneralFused,
5894 kept: &[(usize, bool)],
5895 a: &Shape,
5896 b: &Shape,
5897 tol: Tolerances,
5898) -> OgeomResult<Built> {
5899 ogeom_core::progress::stage("boolean: assemble");
5900 let mut history = History::new();
5901 let source_face = |piece: &FacePiece| -> Shape {
5902 if piece.from_a {
5903 fused.a.faces[piece.face].face.clone()
5904 } else {
5905 fused.b.faces[piece.face].face.clone()
5906 }
5907 };
5908
5909 if kept.is_empty() {
5910 // A legitimate answer: cutting a solid away entirely leaves nothing.
5911 let empty = model.add_compound(&[])?;
5912 for piece in &fused.pieces {
5913 history.delete(&source_face(piece));
5914 }
5915 history.modify(a, empty.clone());
5916 history.modify(b, empty.clone());
5917 return Ok(Built::new(empty, history));
5918 }
5919
5920 let weld = fused
5921 .contacts
5922 .iter()
5923 .map(|c| c.tolerance)
5924 .chain(fused.sections.iter().map(|s| s.tolerance))
5925 .fold(0.0_f64, |acc, t| acc.max(honest(t, tol) * 3.0));
5926 let floor = weld.max(tol.confusion() * 1e2);
5927 let junction_reach = fused
5928 .junctions
5929 .iter()
5930 .fold(0.0_f64, |acc, j| acc.max(j.reach));
5931 let mut junction_bins = bins::Bins::new(junction_reach + floor);
5932 for (index, j) in fused.junctions.iter().enumerate() {
5933 junction_bins.insert(j.at, index);
5934 }
5935 // The vertices' cells are as wide as the loosest tolerance a strand
5936 // brings to its ends, so an end is compared across a few cells, not
5937 // every vertex.
5938 let loosest = fused
5939 .a
5940 .faces
5941 .iter()
5942 .chain(fused.b.faces.iter())
5943 .flat_map(|f| f.edges.iter().map(|e| e.tolerance))
5944 .chain(fused.contacts.iter().map(|c| c.tolerance))
5945 .chain(fused.sections.iter().map(|s| s.tolerance))
5946 .fold(junction_reach, f64::max);
5947 let mut rebuild = Rebuild {
5948 model,
5949 surfaces_a: vec![None; fused.a.faces.len()],
5950 surfaces_b: vec![None; fused.b.faces.len()],
5951 vertices: Vec::new(),
5952 vertex_bins: bins::Bins::new(floor + loosest),
5953 widest: 0.0,
5954 weld,
5955 junctions: fused.junctions.iter().map(|j| (*j, None)).collect(),
5956 junction_bins,
5957 junction_reach,
5958 onto_vertex: std::collections::HashSet::new(),
5959 };
5960 let mut faces = Vec::new();
5961 let mut kept_sources: std::collections::HashSet<Shape> = std::collections::HashSet::new();
5962 for &(index, flip) in kept {
5963 let piece = &fused.pieces[index];
5964 let mut built = build_piece(&mut rebuild, fused, piece, tol)?;
5965 if flip {
5966 built = built.reversed();
5967 }
5968 history.modify(&source_face(piece), built.clone());
5969 kept_sources.insert(source_face(piece));
5970 faces.push(built);
5971 }
5972 for piece in &fused.pieces {
5973 let source = source_face(piece);
5974 if !kept_sources.contains(&source) {
5975 history.delete(&source);
5976 }
5977 }
5978
5979 let model = rebuild.model;
5980 let sewn = sew(model, &faces, tol)?;
5981 for shell in &sewn.shells {
5982 if !is_shell_closed(model, shell)? {
5983 // Env-gated forensics: the open shell's unshared edges, the
5984 // question every failure here starts from.
5985 if *ARRANGE_DEBUG {
5986 use ogeom_geom::Curve3d as _;
5987 for edge in ogeom_topo::explore_unique(model, shell, ShapeType::Edge)? {
5988 let users = ogeom_topo::explore(model, shell, Filter::OfType(ShapeType::Face))?
5989 .iter()
5990 .filter(|f| {
5991 ogeom_topo::explore_unique(model, f, ShapeType::Edge)
5992 .map(|es| es.iter().any(|e2| e2.node() == edge.node()))
5993 .unwrap_or(false)
5994 })
5995 .count();
5996 let mut occurrences = 0_usize;
5997 for f in ogeom_topo::explore(model, shell, Filter::OfType(ShapeType::Face))? {
5998 for wire in model.children_of(&f)? {
5999 for e2 in model.children_of(&wire)? {
6000 if e2.node() == edge.node() {
6001 occurrences += 1;
6002 }
6003 }
6004 }
6005 }
6006 if occurrences % 2 == 1
6007 && let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge())
6008 && let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) =
6009 data.curve3d()
6010 && let Some(g) = model.geometry().curve(*curve)
6011 {
6012 let a = g.point_at(range.0, tol)?;
6013 let b = g.point_at(range.1, tol)?;
6014 eprintln!(
6015 " open edge uses={occurrences} faces={users} {:?} range={range:?}: {a:?} -> {b:?}",
6016 core::mem::discriminant(g)
6017 );
6018 // Twins: other edges of the shell with the same ends,
6019 // which the sew should have found to be this edge.
6020 for other in ogeom_topo::explore_unique(model, shell, ShapeType::Edge)? {
6021 if other.node() == edge.node() {
6022 continue;
6023 }
6024 let Some(od) = model.node(&other).and_then(|n| n.data().as_edge())
6025 else {
6026 continue;
6027 };
6028 let Some(ogeom_topo::EdgeRepr::Curve3d {
6029 curve: oc,
6030 range: orange,
6031 ..
6032 }) = od.curve3d()
6033 else {
6034 continue;
6035 };
6036 let Some(og) = model.geometry().curve(*oc) else {
6037 continue;
6038 };
6039 let oa = og.point_at(orange.0, tol)?;
6040 let ob = og.point_at(orange.1, tol)?;
6041 let same = (oa.distance(a) <= 1e-3 && ob.distance(b) <= 1e-3)
6042 || (oa.distance(b) <= 1e-3 && ob.distance(a) <= 1e-3);
6043 if same {
6044 let om = og.point_at(f64::midpoint(orange.0, orange.1), tol)?;
6045 let m = g.point_at(f64::midpoint(range.0, range.1), tol)?;
6046 eprintln!(
6047 " twin {:?} range={orange:?} mid gap {:.2e} tol {:.2e} vs {:.2e}",
6048 core::mem::discriminant(og),
6049 om.distance(m),
6050 od.tolerance.get(),
6051 data.tolerance.get()
6052 );
6053 }
6054 }
6055 for f in ogeom_topo::explore(model, shell, Filter::OfType(ShapeType::Face))?
6056 {
6057 let uses_it = ogeom_topo::explore_unique(model, &f, ShapeType::Edge)
6058 .map(|es| es.iter().any(|e2| e2.node() == edge.node()))
6059 .unwrap_or(false);
6060 if !uses_it {
6061 continue;
6062 }
6063 if let Some(ogeom_topo::NodeData::Face(fd)) =
6064 model.node(&f).map(|n| n.data())
6065 && let Some(sg) = model.geometry().surface(fd.surface)
6066 {
6067 let bound = shape_bounds(model, &f, tol)?;
6068 eprintln!(
6069 " used by face on {:?} bound {:?}",
6070 core::mem::discriminant(sg),
6071 bound
6072 );
6073 }
6074 }
6075 }
6076 }
6077 }
6078 if *ARRANGE_DEBUG {
6079 use ogeom_geom::Curve3d as _;
6080 for f in ogeom_topo::explore(model, shell, Filter::OfType(ShapeType::Face))? {
6081 let Some(ogeom_topo::NodeData::Face(fd)) = model.node(&f).map(|n| n.data())
6082 else {
6083 continue;
6084 };
6085 let Some(sg) = model.geometry().surface(fd.surface) else {
6086 continue;
6087 };
6088 let edges = ogeom_topo::explore_unique(model, &f, ShapeType::Edge)?;
6089 eprintln!(
6090 " face {:?} with {} edges",
6091 core::mem::discriminant(sg),
6092 edges.len()
6093 );
6094 if matches!(sg, SurfaceGeometry::Sphere(_)) {
6095 for e in &edges {
6096 if let Some(ed) = model.node(e).and_then(|n| n.data().as_edge())
6097 && let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) =
6098 ed.curve3d()
6099 && let Some(g) = model.geometry().curve(*curve)
6100 {
6101 let a = g.point_at(range.0, tol)?;
6102 let b = g.point_at(range.1, tol)?;
6103 eprintln!(
6104 " sphere edge {:?} ({:.4},{:.4},{:.4}) -> ({:.4},{:.4},{:.4})",
6105 core::mem::discriminant(g),
6106 a.x,
6107 a.y,
6108 a.z,
6109 b.x,
6110 b.y,
6111 b.z
6112 );
6113 }
6114 }
6115 }
6116 }
6117 }
6118 ogeom_bail!(
6119 NotDone,
6120 "the kept pieces did not close into a shell; the configuration \
6121 is beyond what the boolean currently resolves"
6122 );
6123 }
6124 }
6125
6126 // Nest: a shell whose bound sits inside another's is that solid's void.
6127 let mut bounds = Vec::new();
6128 for shell in &sewn.shells {
6129 bounds.push(shape_bounds(model, shell, tol)?);
6130 }
6131 let mut solids = Vec::new();
6132 for (i, shell) in sewn.shells.iter().enumerate() {
6133 let contained = bounds
6134 .iter()
6135 .enumerate()
6136 .any(|(j, other)| j != i && other.contains_box(&bounds[i]));
6137 if contained {
6138 continue;
6139 }
6140 let mut group = vec![shell.clone()];
6141 for (j, candidate) in sewn.shells.iter().enumerate() {
6142 if j != i && bounds[i].contains_box(&bounds[j]) {
6143 group.push(candidate.clone());
6144 }
6145 }
6146 solids.push(model.add_solid(&group)?);
6147 }
6148 let result = if solids.len() == 1 {
6149 solids.remove(0)
6150 } else {
6151 model.add_compound(&solids)?
6152 };
6153 history.modify(a, result.clone());
6154 history.modify(b, result.clone());
6155 Ok(Built::new(result, history))
6156}
6157
6158/// Solids from an unordered soup of faces: sew, demand closure, nest
6159/// shells into solids and voids: the pipeline's own final stages offered
6160/// as a builder.
6161///
6162/// # Errors
6163///
6164/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
6165/// shell fails to close; an open soup encloses no volume, and saying so
6166/// beats guessing.
6167pub fn make_volume(model: &mut Model, faces: &[Shape], tol: Tolerances) -> OgeomResult<Built> {
6168 let sewn = ogeom_algo::sew(model, faces, tol)?;
6169 for shell in &sewn.shells {
6170 if !ogeom_algo::is_shell_closed(model, shell)? {
6171 ogeom_bail!(
6172 Construction,
6173 "the faces do not close into shells; an open soup encloses no volume"
6174 );
6175 }
6176 }
6177 let mut bounds = Vec::new();
6178 for shell in &sewn.shells {
6179 bounds.push(ogeom_algo::shape_bounds(model, shell, tol)?);
6180 }
6181 let mut solids = Vec::new();
6182 for (i, shell) in sewn.shells.iter().enumerate() {
6183 let contained = bounds
6184 .iter()
6185 .enumerate()
6186 .any(|(j, other)| j != i && other.contains_box(&bounds[i]));
6187 if contained {
6188 continue;
6189 }
6190 let mut group = vec![shell.clone()];
6191 for (j, candidate) in sewn.shells.iter().enumerate() {
6192 if j != i && bounds[i].contains_box(&bounds[j]) {
6193 // A void bounds its solid from inside: material lies outside
6194 // it, so the sewn outward orientation reverses.
6195 group.push(candidate.reversed());
6196 }
6197 }
6198 solids.push(model.add_solid(&group)?);
6199 }
6200 let mut history = History::new();
6201 let result = if solids.len() == 1 {
6202 solids.remove(0)
6203 } else {
6204 model.add_compound(&solids)?
6205 };
6206 for face in faces {
6207 history.modify(face, result.clone());
6208 }
6209 Ok(Built::new(result, history))
6210}
6211
6212/// The three cells two solids cut space into, each one boolean's answer:
6213/// what is only in `a`, what is only in `b`, and what is in both. Arbitrary
6214/// set expressions compose by fusing a selection of these.
6215#[derive(Debug)]
6216pub struct Cells {
6217 /// `a` with `b` removed.
6218 pub a_not_b: Built,
6219 /// `b` with `a` removed.
6220 pub b_not_a: Built,
6221 /// The overlap.
6222 pub common: Built,
6223}
6224
6225/// Split two solids into their three cells.
6226///
6227/// # Errors
6228///
6229/// As the operations themselves.
6230pub fn cells(model: &mut Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<Cells> {
6231 Ok(Cells {
6232 a_not_b: cut(model, a, b, tol)?,
6233 b_not_a: cut(model, b, a, tol)?,
6234 common: common(model, a, b, tol)?,
6235 })
6236}
6237
6238/// A tolerance whose confusion *is* the stated fuzz: every gap, pave and
6239/// weld decision inherits it coherently, which is what a fuzzy boolean
6240/// means.
6241fn fuzzed(fuzz: f64, tol: Tolerances) -> OgeomResult<Tolerances> {
6242 if !fuzz.is_finite() || fuzz <= 0.0 {
6243 ogeom_bail!(Construction, "a fuzz of {fuzz} is not a distance");
6244 }
6245 if fuzz <= tol.confusion() {
6246 return Ok(tol);
6247 }
6248 Tolerances::with_scale(ogeom_core::tolerance::CONFUSION / fuzz)
6249}
6250
6251/// [`fuse`] with geometry within `fuzz` of touching counted as touching.
6252///
6253/// # Errors
6254///
6255/// As [`fuse`], plus a non-positive fuzz.
6256pub fn fuse_fuzzy(
6257 model: &mut Model,
6258 a: &Shape,
6259 b: &Shape,
6260 fuzz: f64,
6261 tol: Tolerances,
6262) -> OgeomResult<Built> {
6263 let loosened = fuzzed(fuzz, tol)?;
6264 fuse(model, a, b, loosened)
6265}
6266
6267/// [`cut`] at a stated fuzz.
6268///
6269/// # Errors
6270///
6271/// As [`fuse_fuzzy`].
6272pub fn cut_fuzzy(
6273 model: &mut Model,
6274 a: &Shape,
6275 b: &Shape,
6276 fuzz: f64,
6277 tol: Tolerances,
6278) -> OgeomResult<Built> {
6279 let loosened = fuzzed(fuzz, tol)?;
6280 cut(model, a, b, loosened)
6281}
6282
6283/// A shape repeated `count` times along a direction at a period and fused
6284/// into one: the periodic pattern as a composition of what exists.
6285///
6286/// # Errors
6287///
6288/// As [`fuse`], plus an unusable count or period.
6289pub fn make_periodic(
6290 model: &mut Model,
6291 shape: &Shape,
6292 step: ogeom_math::Vector,
6293 count: usize,
6294 tol: Tolerances,
6295) -> OgeomResult<Built> {
6296 if count == 0 {
6297 ogeom_bail!(Construction, "a pattern of zero copies is nothing");
6298 }
6299 if step.magnitude() <= tol.confusion() {
6300 ogeom_bail!(Construction, "a zero period stacks every copy on the first");
6301 }
6302 let mut history = History::new();
6303 let mut result = shape.clone();
6304 for i in 1..count {
6305 #[allow(clippy::cast_precision_loss, reason = "pattern counts are small")]
6306 let offset = step * i as f64;
6307 let moved =
6308 ogeom_algo::transformed(model, shape, ogeom_math::Transform::translation(offset))?
6309 .shape;
6310 let joined = fuse(model, &result, &moved, tol)?;
6311 history.modify(&moved, joined.shape.clone());
6312 result = joined.shape;
6313 }
6314 history.generate(shape, result.clone());
6315 Ok(Built::new(result, history))
6316}
6317
6318// --- the operations ----------------------------------------------------------
6319
6320/// A shape whose placements carry scale, rebuilt with the scale baked
6321/// into its geometry before the pipeline runs.
6322///
6323/// A scale changes a surface's parameterization out from under its
6324/// pcurves (the old refusal), so the bake is the whole-shape conversion:
6325/// surfaces restated in world space, edges moved exactly, pcurves
6326/// re-derived against the new parameterizations. Unscaled shapes pass
6327/// through untouched.
6328fn baked_if_scaled(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
6329 let mut restate = false;
6330 for face in ogeom_topo::explore(model, shape, Filter::OfType(ShapeType::Face))? {
6331 let placement = face.transform(model.datums())?;
6332 // A scale changes lengths the melt compares; a reflection flips
6333 // every chart's natural normal against its face's flag. Either way
6334 // the operand is restated in world coordinates first, where both
6335 // effects are already folded in.
6336 if (placement.scale_factor().abs() - 1.0).abs() > 1e-9 || !placement.preserves_handedness()
6337 {
6338 restate = true;
6339 break;
6340 }
6341 }
6342 if !restate {
6343 return Ok(shape.clone());
6344 }
6345 Ok(ogeom_algo::baked_shape(model, shape, tol)?.shape)
6346}
6347
6348/// Whether a shape is a half space: one shell of one face, open by
6349/// construction or closed inside out.
6350fn is_half_space(model: &Model, shape: &Shape) -> OgeomResult<bool> {
6351 Ok(half_space::half_space_face(model, shape, Tolerances::millimetres())?.is_some())
6352}
6353
6354/// A half space resolved into the solid the operation can act on.
6355///
6356/// A planar boundary becomes a box filling the material side, sized past
6357/// the other argument's whole reach. The box's plane-side face is
6358/// *coplanar with the boundary itself*, so the cut the caller sees is the
6359/// exact plane; its far faces stand outside everything the other shape
6360/// reaches and never appear in the result. A curved boundary is resolved
6361/// by [`half_space::resolved`]. A shape that is not a half space passes
6362/// through untouched.
6363fn resolved_half_space(
6364 model: &mut Model,
6365 shape: &Shape,
6366 other: &Shape,
6367 tol: Tolerances,
6368) -> OgeomResult<Shape> {
6369 let Some(face) = half_space::half_space_face(model, shape, tol)? else {
6370 return Ok(shape.clone());
6371 };
6372 let planar = model
6373 .node(&face)
6374 .and_then(|n| n.data().as_face())
6375 .and_then(|d| model.geometry().surface(d.surface))
6376 .is_some_and(|s| matches!(s, SurfaceGeometry::Plane(_)));
6377 if !planar {
6378 return half_space::resolved(model, &face, other, tol);
6379 }
6380 // The boundary's outward normal points away from the material.
6381 let (at, outward) = ogeom_algo::face_normal(model, &face, tol)?;
6382 let bound = ogeom_algo::shape_bounds(model, other, tol)?;
6383 let Some(centre) = bound.centre() else {
6384 ogeom_bail!(
6385 Construction,
6386 "the other argument has no bound to fill against"
6387 );
6388 };
6389 let reach = bound.diagonal().max(tol.confusion() * 1e3) * 2.0;
6390 let into = ogeom_math::Direction::new(-outward, tol)?;
6391 let foot = centre - outward * outward.dot(centre - at);
6392 let seed = if into.vector().x.abs() < 0.9 {
6393 ogeom_math::Vector::new(1.0, 0.0, 0.0)
6394 } else {
6395 ogeom_math::Vector::new(0.0, 1.0, 0.0)
6396 };
6397 let frame_x = ogeom_math::Direction::from_cross(into.vector(), seed, tol)?;
6398 let oriented = ogeom_math::Frame::new(foot, into, frame_x, tol)?;
6399 let corner =
6400 foot - oriented.x().vector() * (reach / 2.0) - oriented.y().vector() * (reach / 2.0);
6401 let placed = ogeom_math::Frame::new(corner, into, frame_x, tol)?;
6402 Ok(ogeom_algo::make_box(model, placed, (reach, reach, reach), tol)?.shape)
6403}
6404
6405/// The union of two solids.
6406///
6407/// # Errors
6408///
6409/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) for configurations the
6410/// boolean refuses: tangential or same-domain contact, scaled placements;
6411/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) for arguments
6412/// that are not closed solids.
6413pub fn fuse(model: &mut Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<Built> {
6414 if is_half_space(model, a)? || is_half_space(model, b)? {
6415 ogeom_bail!(
6416 Construction,
6417 "the union with a half space is unbounded; a half space serves cut, \
6418 common and section"
6419 );
6420 }
6421 let (a, b) = (
6422 &baked_if_scaled(model, a, tol)?,
6423 &baked_if_scaled(model, b, tol)?,
6424 );
6425 let fused = general_fuse(model, a, b, tol)?;
6426 // Outward pieces bound the union; a same-domain pair with aligned
6427 // material keeps one copy, and one with opposed material is interior to
6428 // the union and vanishes.
6429 let kept: Vec<(usize, bool)> = fused
6430 .pieces
6431 .iter()
6432 .enumerate()
6433 .filter(|(_, p)| {
6434 p.state == PieceState::Out || (p.state == PieceState::OnAligned && !p.covered)
6435 })
6436 .map(|(i, _)| (i, false))
6437 .collect();
6438 assemble_result(model, &fused, &kept, a, b, tol)
6439}
6440
6441/// The intersection of two solids.
6442///
6443/// # Errors
6444///
6445/// As [`fuse`].
6446pub fn common(model: &mut Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<Built> {
6447 let (a, b) = (
6448 &resolved_half_space(model, a, b, tol)?,
6449 &resolved_half_space(model, b, a, tol)?,
6450 );
6451 let (a, b) = (
6452 &baked_if_scaled(model, a, tol)?,
6453 &baked_if_scaled(model, b, tol)?,
6454 );
6455 let fused = general_fuse(model, a, b, tol)?;
6456 // Inward pieces bound the intersection; an aligned same-domain pair
6457 // bounds it too, once. An opposed pair encloses no volume between them.
6458 let kept: Vec<(usize, bool)> = fused
6459 .pieces
6460 .iter()
6461 .enumerate()
6462 .filter(|(_, p)| {
6463 p.state == PieceState::In || (p.state == PieceState::OnAligned && !p.covered)
6464 })
6465 .map(|(i, _)| (i, false))
6466 .collect();
6467 assemble_result(model, &fused, &kept, a, b, tol)
6468}
6469
6470/// The first solid with the second removed.
6471///
6472/// The pieces of `b` that close the cut into `a`'s material bound the removed
6473/// volume from `b`'s side, so they join the result with their material side
6474/// flipped.
6475///
6476/// # Errors
6477///
6478/// As [`fuse`].
6479pub fn cut(model: &mut Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<Built> {
6480 let (a, b) = (
6481 &resolved_half_space(model, a, b, tol)?,
6482 &resolved_half_space(model, b, a, tol)?,
6483 );
6484 let (a, b) = (
6485 &baked_if_scaled(model, a, tol)?,
6486 &baked_if_scaled(model, b, tol)?,
6487 );
6488 let fused = general_fuse(model, a, b, tol)?;
6489 // The first argument's outward pieces stay; the tool's inward pieces
6490 // close the cut with their material side flipped. On the shared surface:
6491 // an opposed pair means the tool's material is entirely on the other
6492 // side, so the first argument's face survives untouched; an aligned pair
6493 // means the tool's material backs the same wall, which the cut removes.
6494 let kept: Vec<(usize, bool)> = fused
6495 .pieces
6496 .iter()
6497 .enumerate()
6498 .filter_map(|(i, p)| match (p.from_a, p.state) {
6499 (true, PieceState::Out) => Some((i, false)),
6500 (true, PieceState::OnOpposed) => Some((i, false)),
6501 (false, PieceState::In) => Some((i, true)),
6502 _ => None,
6503 })
6504 .collect();
6505 assemble_result(model, &fused, &kept, a, b, tol)
6506}
6507
6508/// The edges where the two solids' boundaries cross.
6509///
6510/// # Errors
6511///
6512/// As [`fuse`].
6513pub fn section(model: &mut Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<Built> {
6514 let (a, b) = (
6515 &resolved_half_space(model, a, b, tol)?,
6516 &resolved_half_space(model, b, a, tol)?,
6517 );
6518 let (a, b) = (
6519 &baked_if_scaled(model, a, tol)?,
6520 &baked_if_scaled(model, b, tol)?,
6521 );
6522 let fused = general_fuse(model, a, b, tol)?;
6523 // Every section sub-edge that survived into some piece's ring, built
6524 // once per distinct sub-range.
6525 let mut wanted: Vec<(usize, (f64, f64))> = Vec::new();
6526 for piece in &fused.pieces {
6527 for ring in &piece.rings {
6528 for traversal in ring {
6529 if let Tag::Section { section, range } = &traversal.tag {
6530 let near = |a: (f64, f64), b: (f64, f64)| {
6531 (a.0 - b.0).abs() <= tol.parametric()
6532 && (a.1 - b.1).abs() <= tol.parametric()
6533 };
6534 if !wanted.iter().any(|(s, r)| s == section && near(*r, *range)) {
6535 wanted.push((*section, *range));
6536 }
6537 }
6538 }
6539 }
6540 }
6541 let mut history = History::new();
6542 let mut edges = Vec::new();
6543 for (si, range) in wanted {
6544 let s = &fused.sections[si];
6545 let domain = s.curve.domain();
6546 let (f0, f1) = folded_range(range, domain, s.closed);
6547 let from = s.curve.point_at(at_param(f0, domain, s.closed), tol)?;
6548 let to = s.curve.point_at(at_param(f1, domain, s.closed), tol)?;
6549 let v0 = make_vertex(model, from).shape;
6550 let v1 = make_vertex(model, to).shape;
6551 edges.push(make_edge_between(model, s.curve.clone(), (f0, f1), &v0, &v1, tol)?.shape);
6552 }
6553 // Contacts are not crossings, so no piece's ring carries them and the
6554 // loop above cannot see them, but a section through a tangency has a
6555 // curve in it, and this is where it comes from.
6556 for contact in &fused.tangents {
6557 for (lo, hi) in contact_intervals(&fused, contact, tol)? {
6558 let from = contact.curve.point_at(lo, tol)?;
6559 let to = contact.curve.point_at(hi, tol)?;
6560 let v0 = make_vertex(model, from).shape;
6561 let v1 = if from.distance(to) <= tol.confusion() {
6562 v0.clone()
6563 } else {
6564 make_vertex(model, to).shape
6565 };
6566 edges.push(
6567 make_edge_between(model, contact.curve.clone(), (lo, hi), &v0, &v1, tol)?.shape,
6568 );
6569 }
6570 }
6571 let result = model.add_compound(&edges)?;
6572 history.modify(a, result.clone());
6573 history.modify(b, result.clone());
6574 Ok(Built::new(result, history))
6575}
6576
6577#[cfg(test)]
6578#[allow(clippy::unwrap_used)]
6579mod tests {
6580 use super::*;
6581 use ogeom_algo::{check, make_box, make_cylinder, volume_properties};
6582 use ogeom_math::{Direction, Frame};
6583 use ogeom_mesh::Deflection;
6584
6585 const T: Tolerances = Tolerances::millimetres();
6586 const PI: f64 = core::f64::consts::PI;
6587
6588 #[test]
6589 fn coincidence_is_measured_over_the_overlap_and_nowhere_else() {
6590 // Patches restated from planes: the geometry no longer says "plane",
6591 // which is the whole reason this measurement exists.
6592 let patch = |plane: ogeom_math::Plane, u: (f64, f64), v: (f64, f64)| {
6593 let surface: SurfaceGeometry =
6594 ogeom_geom::PlaneSurface::over(plane, u, v).unwrap().into();
6595 SurfaceGeometry::from(surface.to_bspline(T).unwrap())
6596 };
6597 let reach = T.confusion() * 1e2;
6598
6599 // Two windows on one plane, overlapping over a quarter of each. They
6600 // are the same surface exactly where they meet, which is the claim.
6601 let here = patch(ogeom_math::Plane::XY, (0.0, 10.0), (0.0, 10.0));
6602 let over = patch(ogeom_math::Plane::XY, (5.0, 15.0), (5.0, 15.0));
6603 assert!(surfaces_coincide(&here, &over, reach, T));
6604
6605 // The same plane lifted clear of itself is not the same surface, and
6606 // a plane square to it crosses rather than coincides: the case that
6607 // must keep marching, since a crossing has a section to find.
6608 let above = patch(
6609 ogeom_math::Plane::new(
6610 Frame::new(Point::new(0.0, 0.0, 1.0), Direction::Z, Direction::X, T).unwrap(),
6611 ),
6612 (0.0, 10.0),
6613 (0.0, 10.0),
6614 );
6615 assert!(!surfaces_coincide(&here, &above, reach, T));
6616 let across = patch(
6617 ogeom_math::Plane::new(
6618 Frame::new(Point::new(5.0, 0.0, 0.0), Direction::X, Direction::Y, T).unwrap(),
6619 ),
6620 (0.0, 10.0),
6621 (0.0, 10.0),
6622 );
6623 assert!(!surfaces_coincide(&here, &across, reach, T));
6624 }
6625
6626 fn frame_at(origin: Point) -> Frame {
6627 Frame::new(origin, Direction::Z, Direction::X, T).unwrap()
6628 }
6629
6630 fn boxes(model: &mut Model) -> (Shape, Shape) {
6631 // Overlapping in the corner cube [1,2]^3.
6632 let a = make_box(model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
6633 let b = make_box(
6634 model,
6635 frame_at(Point::new(1.0, 1.0, 1.0)),
6636 (2.0, 2.0, 2.0),
6637 T,
6638 )
6639 .unwrap();
6640 (a.shape, b.shape)
6641 }
6642
6643 fn volume(model: &Model, shape: &Shape) -> f64 {
6644 let fine = Deflection {
6645 chord: 1e-3,
6646 ..Deflection::default()
6647 };
6648 volume_properties(model, shape, fine, T).unwrap().mass
6649 }
6650
6651 fn assert_valid(model: &Model, shape: &Shape) {
6652 let diagnosis = check(model, shape, T).unwrap();
6653 assert!(
6654 diagnosis.is_valid(),
6655 "the result fails validity: {:?}",
6656 diagnosis.problems
6657 );
6658 }
6659
6660 #[test]
6661 fn fuse_of_overlapping_boxes_has_the_inclusion_exclusion_volume() {
6662 let mut model = Model::new();
6663 let (a, b) = boxes(&mut model);
6664 let fused = fuse(&mut model, &a, &b, T).unwrap();
6665 assert_valid(&model, &fused.shape);
6666 assert!((volume(&model, &fused.shape) - 15.0).abs() < 1e-9);
6667 assert_eq!(
6668 fused.history.modified(&a),
6669 std::slice::from_ref(&fused.shape)
6670 );
6671 }
6672
6673 #[test]
6674 fn common_of_overlapping_boxes_is_the_overlap_cube() {
6675 let mut model = Model::new();
6676 let (a, b) = boxes(&mut model);
6677 let result = common(&mut model, &a, &b, T).unwrap();
6678 assert_valid(&model, &result.shape);
6679 assert!((volume(&model, &result.shape) - 1.0).abs() < 1e-9);
6680 }
6681
6682 #[test]
6683 fn cut_removes_the_overlap_from_the_first_argument() {
6684 let mut model = Model::new();
6685 let (a, b) = boxes(&mut model);
6686 let result = cut(&mut model, &a, &b, T).unwrap();
6687 assert_valid(&model, &result.shape);
6688 assert!((volume(&model, &result.shape) - 7.0).abs() < 1e-9);
6689 }
6690
6691 #[test]
6692 fn section_of_overlapping_boxes_is_the_six_segment_seam() {
6693 let mut model = Model::new();
6694 let (a, b) = boxes(&mut model);
6695 let result = section(&mut model, &a, &b, T).unwrap();
6696 let edges = explore(&model, &result.shape, Filter::OfType(ShapeType::Edge)).unwrap();
6697 assert_eq!(edges.len(), 6);
6698 }
6699
6700 #[test]
6701 fn fuse_of_disjoint_boxes_is_a_compound_of_both() {
6702 let mut model = Model::new();
6703 let a = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
6704 let b = make_box(
6705 &mut model,
6706 frame_at(Point::new(5.0, 0.0, 0.0)),
6707 (2.0, 2.0, 2.0),
6708 T,
6709 )
6710 .unwrap();
6711 let fused = fuse(&mut model, &a.shape, &b.shape, T).unwrap();
6712 assert_eq!(model.kind_of(&fused.shape).unwrap(), ShapeType::Compound);
6713 assert!((volume(&model, &fused.shape) - 16.0).abs() < 1e-9);
6714 }
6715
6716 #[test]
6717 fn cutting_a_through_post_leaves_a_slab_with_a_hole() {
6718 let mut model = Model::new();
6719 let slab = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
6720 let post = make_box(
6721 &mut model,
6722 frame_at(Point::new(1.5, 1.5, -1.0)),
6723 (1.0, 1.0, 3.0),
6724 T,
6725 )
6726 .unwrap();
6727 let result = cut(&mut model, &slab.shape, &post.shape, T).unwrap();
6728 assert_valid(&model, &result.shape);
6729 assert!((volume(&model, &result.shape) - 15.0).abs() < 1e-9);
6730 }
6731
6732 #[test]
6733 fn common_with_a_contained_box_is_that_box() {
6734 let mut model = Model::new();
6735 let outer = make_box(&mut model, Frame::WORLD, (6.0, 6.0, 6.0), T).unwrap();
6736 let inner = make_box(
6737 &mut model,
6738 frame_at(Point::new(2.0, 2.0, 2.0)),
6739 (2.0, 2.0, 2.0),
6740 T,
6741 )
6742 .unwrap();
6743 let result = common(&mut model, &outer.shape, &inner.shape, T).unwrap();
6744 assert_valid(&model, &result.shape);
6745 assert!((volume(&model, &result.shape) - 8.0).abs() < 1e-9);
6746 }
6747
6748 #[test]
6749 fn cutting_everything_away_leaves_an_empty_compound() {
6750 let mut model = Model::new();
6751 let outer = make_box(&mut model, Frame::WORLD, (6.0, 6.0, 6.0), T).unwrap();
6752 let inner = make_box(
6753 &mut model,
6754 frame_at(Point::new(2.0, 2.0, 2.0)),
6755 (2.0, 2.0, 2.0),
6756 T,
6757 )
6758 .unwrap();
6759 let result = cut(&mut model, &inner.shape, &outer.shape, T).unwrap();
6760 assert_eq!(model.kind_of(&result.shape).unwrap(), ShapeType::Compound);
6761 assert!(
6762 explore(&model, &result.shape, Filter::OfType(ShapeType::Face))
6763 .unwrap()
6764 .is_empty()
6765 );
6766 }
6767
6768 #[test]
6769 fn drilling_a_box_leaves_a_cylindrical_hole() {
6770 // The curved milestone: box minus a through-post cylinder. The box's
6771 // top and bottom faces come back with *circular* holes, the hole's
6772 // wall is the cylinder's own surface with its material side flipped,
6773 // and the cylinder's seam and both section circles all had to split
6774 // and sew for the shell to close.
6775 let mut model = Model::new();
6776 let block = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
6777 let drill = make_cylinder(
6778 &mut model,
6779 frame_at(Point::new(2.0, 2.0, -1.0)),
6780 0.5,
6781 3.0,
6782 T,
6783 )
6784 .unwrap();
6785 let result = cut(&mut model, &block.shape, &drill.shape, T).unwrap();
6786 assert_valid(&model, &result.shape);
6787 let exact = 16.0 - PI * 0.25;
6788 let got = volume(&model, &result.shape);
6789 assert!(
6790 (got - exact).abs() / exact < 2e-3,
6791 "volume {got} against {exact}"
6792 );
6793 }
6794
6795 #[test]
6796 fn common_of_a_box_and_a_cylinder_is_the_post_inside_it() {
6797 let mut model = Model::new();
6798 let block = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
6799 let post = make_cylinder(
6800 &mut model,
6801 frame_at(Point::new(2.0, 2.0, -1.0)),
6802 0.5,
6803 3.0,
6804 T,
6805 )
6806 .unwrap();
6807 let result = common(&mut model, &block.shape, &post.shape, T).unwrap();
6808 assert_valid(&model, &result.shape);
6809 let exact = PI * 0.25;
6810 let got = volume(&model, &result.shape);
6811 assert!(
6812 (got - exact).abs() / exact < 2e-3,
6813 "volume {got} against {exact}"
6814 );
6815 }
6816
6817 #[test]
6818 fn fusing_a_post_onto_a_slab_adds_what_stands_proud() {
6819 let mut model = Model::new();
6820 let slab = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
6821 let post = make_cylinder(
6822 &mut model,
6823 frame_at(Point::new(2.0, 2.0, -1.0)),
6824 0.5,
6825 3.0,
6826 T,
6827 )
6828 .unwrap();
6829 let result = fuse(&mut model, &slab.shape, &post.shape, T).unwrap();
6830 assert_valid(&model, &result.shape);
6831 let exact = PI.mul_add(0.25 * 3.0, 16.0) - PI * 0.25;
6832 let got = volume(&model, &result.shape);
6833 assert!(
6834 (got - exact).abs() / exact < 2e-3,
6835 "volume {got} against {exact}"
6836 );
6837 }
6838
6839 #[test]
6840 fn crossed_cylinders_run_through_the_marched_sections() {
6841 // No closed form exists for cylinder/cylinder: the sections are
6842 // marched and fitted, and every stage downstream (paves against
6843 // seams on both charts, splitting, classification, rebuilding with
6844 // fitted pcurves, sewing, meshing) has to work within the fit's
6845 // stated budget. Unequal radii keep the tangential branch points
6846 // away. The volumes have no easy closed form, so the operations are
6847 // held to each other: fuse = A + B - common and cut = A - common are
6848 // identities whatever the shapes.
6849 let mut model = Model::new();
6850 let upright = make_cylinder(&mut model, Frame::WORLD, 1.0, 4.0, T).unwrap();
6851 let across_frame =
6852 Frame::new(Point::new(-2.0, 0.0, 2.0), Direction::X, Direction::Y, T).unwrap();
6853 let across = make_cylinder(&mut model, across_frame, 0.6, 4.0, T).unwrap();
6854
6855 let both = fuse(&mut model, &upright.shape, &across.shape, T).unwrap();
6856 assert_valid(&model, &both.shape);
6857 let shared = common(&mut model, &upright.shape, &across.shape, T).unwrap();
6858 assert_valid(&model, &shared.shape);
6859 let pierced = cut(&mut model, &upright.shape, &across.shape, T).unwrap();
6860 assert_valid(&model, &pierced.shape);
6861
6862 let va = volume(&model, &upright.shape);
6863 let vb = volume(&model, &across.shape);
6864 let vf = volume(&model, &both.shape);
6865 let vc = volume(&model, &shared.shape);
6866 let vx = volume(&model, &pierced.shape);
6867
6868 assert!(vc > 0.0 && vc < vb, "the overlap is real and partial: {vc}");
6869 assert!(
6870 (vf - (va + vb - vc)).abs() / vf < 2e-3,
6871 "fuse {vf} against A + B - common {}",
6872 va + vb - vc
6873 );
6874 assert!(
6875 (vx - (va - vc)).abs() / vx < 2e-3,
6876 "cut {vx} against A - common {}",
6877 va - vc
6878 );
6879 }
6880
6881 #[test]
6882 fn stacked_boxes_fuse_into_one_solid_and_the_contact_vanishes() {
6883 // Same-domain contact with opposed materials: the shared rectangle is
6884 // interior to the union and no face of the result may carry it.
6885 let mut model = Model::new();
6886 let lower = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 1.0), T).unwrap();
6887 let upper = make_box(
6888 &mut model,
6889 frame_at(Point::new(0.0, 0.0, 1.0)),
6890 (2.0, 2.0, 1.0),
6891 T,
6892 )
6893 .unwrap();
6894 let fused = fuse(&mut model, &lower.shape, &upper.shape, T).unwrap();
6895 assert_valid(&model, &fused.shape);
6896 assert_eq!(model.kind_of(&fused.shape).unwrap(), ShapeType::Solid);
6897 assert!((volume(&model, &fused.shape) - 8.0).abs() < 1e-9);
6898 }
6899
6900 #[test]
6901 fn a_small_box_on_a_big_one_fuses_with_a_partial_contact() {
6902 // The big top face splits into the contact rectangle (swallowed)
6903 // and the surround, which stays and must sew to the small box's
6904 // walls along the contact's edges.
6905 let mut model = Model::new();
6906 let big = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
6907 let small = make_box(
6908 &mut model,
6909 frame_at(Point::new(1.0, 1.0, 1.0)),
6910 (2.0, 2.0, 1.0),
6911 T,
6912 )
6913 .unwrap();
6914 let fused = fuse(&mut model, &big.shape, &small.shape, T).unwrap();
6915 assert_valid(&model, &fused.shape);
6916 assert!((volume(&model, &fused.shape) - 20.0).abs() < 1e-9);
6917
6918 // Cutting the same pair removes nothing but the measure-zero contact:
6919 // the big box survives whole, its top face's contact piece intact.
6920 let mut model = Model::new();
6921 let big = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
6922 let small = make_box(
6923 &mut model,
6924 frame_at(Point::new(1.0, 1.0, 1.0)),
6925 (2.0, 2.0, 1.0),
6926 T,
6927 )
6928 .unwrap();
6929 let result = cut(&mut model, &big.shape, &small.shape, T).unwrap();
6930 assert_valid(&model, &result.shape);
6931 assert!((volume(&model, &result.shape) - 16.0).abs() < 1e-9);
6932 }
6933
6934 #[test]
6935 fn flush_walls_fuse_cut_and_meet_with_aligned_contact() {
6936 // Overlapping boxes sharing flush walls: same-domain contact with
6937 // *aligned* materials. A = [0,2]^3, B = [1,3]x[0,2]x[0,2]: the y and
6938 // z walls of the overlap are coplanar with aligned outward normals.
6939 let mut model = Model::new();
6940 let a = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
6941 let b = make_box(
6942 &mut model,
6943 frame_at(Point::new(1.0, 0.0, 0.0)),
6944 (2.0, 2.0, 2.0),
6945 T,
6946 )
6947 .unwrap();
6948 let fused = fuse(&mut model, &a.shape, &b.shape, T).unwrap();
6949 assert_valid(&model, &fused.shape);
6950 assert!((volume(&model, &fused.shape) - 12.0).abs() < 1e-9);
6951
6952 let mut model = Model::new();
6953 let a = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
6954 let b = make_box(
6955 &mut model,
6956 frame_at(Point::new(1.0, 0.0, 0.0)),
6957 (2.0, 2.0, 2.0),
6958 T,
6959 )
6960 .unwrap();
6961 let shared = common(&mut model, &a.shape, &b.shape, T).unwrap();
6962 assert_valid(&model, &shared.shape);
6963 assert!((volume(&model, &shared.shape) - 4.0).abs() < 1e-9);
6964
6965 let mut model = Model::new();
6966 let a = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
6967 let b = make_box(
6968 &mut model,
6969 frame_at(Point::new(1.0, 0.0, 0.0)),
6970 (2.0, 2.0, 2.0),
6971 T,
6972 )
6973 .unwrap();
6974 let cut_result = cut(&mut model, &a.shape, &b.shape, T).unwrap();
6975 assert_valid(&model, &cut_result.shape);
6976 assert!((volume(&model, &cut_result.shape) - 4.0).abs() < 1e-9);
6977 }
6978
6979 #[test]
6980 fn every_source_face_is_accounted_for_in_the_history() {
6981 let mut model = Model::new();
6982 let (a, b) = boxes(&mut model);
6983 let result = cut(&mut model, &a, &b, T).unwrap();
6984 for face in explore(&model, &a, Filter::OfType(ShapeType::Face)).unwrap() {
6985 assert!(
6986 result.history.is_affected(&face),
6987 "a face of the first argument vanished from the history"
6988 );
6989 }
6990 }
6991}