brep_kernel/csg/fragment/face_split.rs
1use super::*;
2use super::types::{Chain, ChainSource};
3use super::sampling::{enforce_boundary_crossing_parity, refine_near_hints, sag_refine_chain, sample_chain, trimmed_curve};
4use super::loops::{build_loop, edge_for, interior_points, pcurve_for, point_in_polygon, vertex_point};
5/// Imprint id-index shared across every face of one operand. Built ONCE in
6/// `fragment_solid`; without it each face rebuilt `pieces_by_id`/`vertex_by_id`
7/// over ALL pieces (O(F*(P + P*V))) and linear-scanned `by_face`. Lookup-only,
8/// so every face gets the identical pieces/points the scans returned.
9struct FragmentIndex<'a> {
10 by_face: HashMap<(u8, u64), &'a [u64]>,
11 pieces_by_id: HashMap<u64, &'a ImprintPieceRecord>,
12 vertex_points: HashMap<u64, Vec3>,
13}
14
15impl<'a> FragmentIndex<'a> {
16 fn build(imprint: &'a ImprintResultRecord) -> Self {
17 let mut by_face: HashMap<(u8, u64), &'a [u64]> = HashMap::default();
18 for entry in &imprint.by_face {
19 // `.entry().or_insert` keeps the FIRST entry, matching the previous
20 // `by_face.iter().find(...)` (keys are unique in practice anyway).
21 by_face
22 .entry((entry.operand, entry.face_id))
23 .or_insert_with(|| entry.piece_ids.as_slice());
24 }
25 FragmentIndex {
26 by_face,
27 pieces_by_id: imprint
28 .pieces
29 .iter()
30 .map(|piece| (piece.id, piece))
31 .collect(),
32 vertex_points: imprint
33 .vertices
34 .iter()
35 .map(|vertex| (vertex.id, vertex.point))
36 .collect(),
37 }
38 }
39}
40
41/// Back-compat entry point: builds the imprint index for this single face.
42/// `fragment_solid` builds it once and calls `fragment_face_indexed` directly.
43pub fn fragment_face(
44 solid: &BrepSolid,
45 operand: u8,
46 face: &FaceRecord,
47 imprint: &ImprintResultRecord,
48) -> Result<Vec<FaceFragmentRecord>, String> {
49 let index = FragmentIndex::build(imprint);
50 fragment_face_indexed(solid, operand, face, &index)
51}
52
53fn fragment_face_indexed(
54 solid: &BrepSolid,
55 operand: u8,
56 face: &FaceRecord,
57 index: &FragmentIndex,
58) -> Result<Vec<FaceFragmentRecord>, String> {
59 let face_key = FaceKey {
60 operand,
61 face_id: face.id,
62 };
63 let piece_ids = index
64 .by_face
65 .get(&(operand, face.id))
66 .copied()
67 .unwrap_or(&[]);
68 if piece_ids.is_empty() {
69 let u = KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?.domain();
70 let v = KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?.domain();
71 let uv = Vec2 {
72 x: (u[0] + u[1]) / 2.0,
73 y: (v[0] + v[1]) / 2.0,
74 };
75 if parameter_point_in_face(face, uv, 1e-9)? != PolygonClass::Inside {
76 // Trimmed faces need the arrangement path even without cuts to
77 // find a point in their actual outer loop.
78 } else {
79 let mut extra_test_points = Vec::new();
80 for (fu, fv) in [(0.35, 0.35), (0.65, 0.65)] {
81 let candidate = Vec2 {
82 x: u[0] + (u[1] - u[0]) * fu,
83 y: v[0] + (v[1] - v[0]) * fv,
84 };
85 if parameter_point_in_face(face, candidate, 1e-9)? == PolygonClass::Inside {
86 extra_test_points.push(face.surface.evaluate(candidate.x, candidate.y)?);
87 }
88 }
89 return Ok(vec![FaceFragmentRecord {
90 operand,
91 source_face_id: face.id,
92 surface: face.surface.clone(),
93 same_sense: face.same_sense,
94 loops: face
95 .loops
96 .iter()
97 .map(|loop_record| FragmentLoop {
98 coedges: loop_record
99 .coedges
100 .iter()
101 .map(|coedge| FragmentCoedge {
102 source: FragmentEdgeSource::Boundary {
103 operand,
104 edge_id: coedge.edge_id,
105 },
106 forward: coedge.forward,
107 pcurve: coedge.pcurve.clone(),
108 })
109 .collect(),
110 })
111 .collect(),
112 test_point: face.surface.evaluate(uv.x, uv.y)?,
113 test_uv: uv,
114 extra_test_points,
115 }]);
116 }
117 }
118
119 let u_domain = KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?.domain();
120 let v_domain = KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?.domain();
121 let diagonal =
122 ((u_domain[1] - u_domain[0]).powi(2) + (v_domain[1] - v_domain[0]).powi(2)).sqrt();
123 let arrangement_tolerance = 1e-7f64.max(diagonal * 1e-6);
124 let snap_threshold = 0.1 * diagonal;
125 let junction_radius = (arrangement_tolerance * 50.0).max(diagonal * 1.5e-3);
126 // WRAPPED-BAND RE-BASE: a face on a closed direction whose material is the
127 // wrapped COMPLEMENT of its trim hull (two full-wrap rims at the hull
128 // edges, material crossing the seam between them — trial 35's torus face:
129 // rims at v=0.25/1.0, material v∈[0,0.25]) cannot be arranged in the flat
130 // domain rectangle: a cut through the band ends at v=0 while its junction
131 // partner (the split rim vertex) sits at v=1, so every cut dangles, cycle
132 // extraction fails, and the face is dropped wholesale (one-use cascade).
133 // Re-base into the material-coherent frame [hull_high, hull_low+period]:
134 // shift every chain point on the low side of the hull window (+period), so
135 // rims and cuts junction in ONE unwrapped chart, and wrap region test
136 // points back into the domain on exit. Detection mirrors the imprint's
137 // restricted-carrier wrap guard: the hull-complement strip contains no
138 // loop curves, so one sample at its middle decides materiality exactly.
139 // Escape hatch: BREP_BAND_REBASE=0.
140 let band_rebase: [Option<(f64, f64)>; 2] = {
141 let mut rebase = [None, None];
142 let closed = face.surface.closed_directions()?;
143 if (closed.0 || closed.1) && std::env::var("BREP_BAND_REBASE").as_deref() != Ok("0") {
144 // Per-coedge hulls on each axis, plus whether any ISO-RIM
145 // (a coedge whose pcurve is near-constant on the axis) sits at a
146 // domain edge — the discriminator for the wrapped-band class.
147 // A seam-straddling boundary band (helmet Face_15, whose source
148 // pcurves are already unwrapped past the domain) has no such rim
149 // and its frame is already coherent; it must NOT be re-based.
150 let mut coverage: [Vec<[f64; 2]>; 2] = [Vec::new(), Vec::new()];
151 let mut edge_rim = [false, false];
152 let mut hull = [
153 [f64::INFINITY, f64::NEG_INFINITY],
154 [f64::INFINITY, f64::NEG_INFINITY],
155 ];
156 for coedge in face
157 .loops
158 .iter()
159 .flat_map(|loop_record| &loop_record.coedges)
160 {
161 let mut extent = [
162 [f64::INFINITY, f64::NEG_INFINITY],
163 [f64::INFINITY, f64::NEG_INFINITY],
164 ];
165 for control in &coedge.pcurve.control_points {
166 if control.w.abs() <= 1e-300 {
167 continue;
168 }
169 let point = [control.x / control.w, control.y / control.w];
170 for axis in 0..2 {
171 extent[axis][0] = extent[axis][0].min(point[axis]);
172 extent[axis][1] = extent[axis][1].max(point[axis]);
173 hull[axis][0] = hull[axis][0].min(point[axis]);
174 hull[axis][1] = hull[axis][1].max(point[axis]);
175 }
176 }
177 for axis in 0..2 {
178 if !extent[axis][0].is_finite() {
179 continue;
180 }
181 let domain = if axis == 0 { u_domain } else { v_domain };
182 let span = domain[1] - domain[0];
183 coverage[axis].push(extent[axis]);
184 let flat = extent[axis][1] - extent[axis][0] <= 1e-3 * span;
185 let at_edge = (extent[axis][0] - domain[0]).abs() <= 1e-6 * span
186 || (extent[axis][1] - domain[1]).abs() <= 1e-6 * span;
187 if flat && at_edge {
188 edge_rim[axis] = true;
189 }
190 }
191 }
192 for axis in 0..2 {
193 if !(if axis == 0 { closed.0 } else { closed.1 }) || !edge_rim[axis] {
194 continue;
195 }
196 let domain = if axis == 0 { u_domain } else { v_domain };
197 let span = domain[1] - domain[0];
198 // Merge the coverage intervals (clamped into the domain) and
199 // find the largest uncovered gap. The gap contains no boundary
200 // curve, so it is uniformly material or uniformly void — one
201 // sample at its middle decides exactly. A strictly INTERIOR
202 // void gap means the material wraps through the domain edge.
203 let mut intervals: Vec<[f64; 2]> = coverage[axis]
204 .iter()
205 .map(|window| {
206 [window[0].max(domain[0]), window[1].min(domain[1])]
207 })
208 .filter(|window| window[1] >= window[0])
209 .collect();
210 intervals.sort_by(|a, b| a[0].total_cmp(&b[0]));
211 let mut gaps: Vec<[f64; 2]> = Vec::new();
212 let mut reach = domain[0];
213 for window in &intervals {
214 if window[0] > reach {
215 gaps.push([reach, window[0]]);
216 }
217 reach = reach.max(window[1]);
218 }
219 if reach < domain[1] {
220 gaps.push([reach, domain[1]]);
221 }
222 let other_mid = (hull[1 - axis][0].max(if axis == 0 {
223 v_domain[0]
224 } else {
225 u_domain[0]
226 }) + hull[1 - axis][1].min(if axis == 0 {
227 v_domain[1]
228 } else {
229 u_domain[1]
230 })) / 2.0;
231 let mut void: Option<[f64; 2]> = None;
232 for gap in gaps {
233 if gap[1] - gap[0] <= 1e-6 * span {
234 continue;
235 }
236 let mid = (gap[0] + gap[1]) / 2.0;
237 let sample = if axis == 0 {
238 Vec2 {
239 x: mid,
240 y: other_mid,
241 }
242 } else {
243 Vec2 {
244 x: other_mid,
245 y: mid,
246 }
247 };
248 if parameter_point_in_face(face, sample, 1e-9)? != PolygonClass::Inside
249 && void
250 .map(|best| gap[1] - gap[0] > best[1] - best[0])
251 .unwrap_or(true)
252 {
253 void = Some(gap);
254 }
255 }
256 if let Some([g0, g1]) = void {
257 // The material+boundary arc wraps the domain edge exactly
258 // when boundary coverage touches BOTH edge representations
259 // (the on-seam rim at one, the material-side chains at the
260 // other) with the void between them. An ordinary band
261 // (material inside the rims) touches only one edge and
262 // must keep the flat frame.
263 let touches_low = intervals
264 .iter()
265 .any(|window| window[0] <= domain[0] + 1e-6 * span);
266 let touches_high = intervals
267 .iter()
268 .any(|window| window[1] >= domain[1] - 1e-6 * span);
269 if touches_low && touches_high {
270 // Shift everything on the low side of the void up one
271 // period so the band is contiguous in the arrangement
272 // frame.
273 rebase[axis] = Some(((g0 + g1) / 2.0, span));
274 }
275 }
276 }
277 }
278 rebase
279 };
280 let rebase_point = |mut point: Vec2| -> Vec2 {
281 if let Some((cutoff, period)) = band_rebase[0] {
282 if point.x < cutoff {
283 point.x += period;
284 }
285 }
286 if let Some((cutoff, period)) = band_rebase[1] {
287 if point.y < cutoff {
288 point.y += period;
289 }
290 }
291 point
292 };
293 // Region test points computed in a re-based or unwrapped frame must be
294 // folded back into the domain before trim classification / evaluation.
295 // Wrapping is gated on the axis being CLOSED (a coherent flat face never
296 // produces out-of-domain interior points, so this is a no-op there).
297 let wrap_back = {
298 let closed = face.surface.closed_directions()?;
299 move |mut point: Vec2| -> Vec2 {
300 if closed.0 {
301 let span = u_domain[1] - u_domain[0];
302 if point.x < u_domain[0] || point.x > u_domain[1] {
303 point.x = u_domain[0] + (point.x - u_domain[0]).rem_euclid(span);
304 }
305 }
306 if closed.1 {
307 let span = v_domain[1] - v_domain[0];
308 if point.y < v_domain[0] || point.y > v_domain[1] {
309 point.y = v_domain[0] + (point.y - v_domain[0]).rem_euclid(span);
310 }
311 }
312 point
313 }
314 };
315 let pieces_by_id = &index.pieces_by_id;
316 let mut cut_hints = Vec::new();
317 let mut active_pieces = Vec::new();
318 let mut cut_keys = HashMap::default();
319 for piece_id in piece_ids {
320 let piece = pieces_by_id
321 .get(piece_id)
322 .ok_or_else(|| "fragment_face: missing imprint piece".to_string())?;
323 let pcurve = pcurve_for(piece, face_key)?;
324 let [start, end] = pcurve.domain()?;
325 let a = pcurve.evaluate(start)?;
326 let b = pcurve.evaluate(end)?;
327 let middle = pcurve.evaluate((start + end) / 2.0)?;
328 for point in [a, b] {
329 cut_hints.push(Vec2 {
330 x: point.x,
331 y: point.y,
332 });
333 }
334 let quantize = |point: Vec3| {
335 (
336 (point.x / arrangement_tolerance).round() as i64,
337 (point.y / arrangement_tolerance).round() as i64,
338 )
339 };
340 let mut endpoints = [quantize(a), quantize(b)];
341 endpoints.sort();
342 let key = (endpoints[0], quantize(middle), endpoints[1]);
343 if cut_keys.insert(key, *piece_id).is_none() {
344 active_pieces.push(*piece_id);
345 }
346 }
347
348 let mut segments = Vec::new();
349 let mut chains = Vec::new();
350 let mut vertex_images: std::collections::HashMap<String, Vec<Vec2>> =
351 std::collections::HashMap::new();
352 let mut endpoint_images: Vec<(Vec3, Vec2)> = Vec::new();
353 // Each boundary image carries its 3D vertex point so the boundary-side
354 // canonicalization can refuse to unify two DISTINCT boundary vertices.
355 let mut boundary_images: Vec<(Vec2, Vec3)> = Vec::new();
356 // BOUNDARY-VERTEX ANCHOR (t427 box-corner graze). The boundary-side snap
357 // below (rule 3) merges a chain endpoint onto a same-side boundary image
358 // within arr_tol*100 with NO 3D check. For a CUT/section terminus that is
359 // the intended near-tangent graze bridge onto a trim edge. For a BOUNDARY
360 // endpoint it is a bug: two genuinely-distinct same-operand boundary
361 // vertices (t427: box corner v8 at v=68.4851 vs the edge-16 split A at
362 // v=68.4783, 6.8e-3 apart on the u=max domain edge, 6.8e-3 apart in 3D ≫
363 // the 1e-5 weld floor) get unified, which droops edge 21's pcurve and
364 // mints a phantom corner lens bounded by derived edges → a one-use cluster
365 // that only strands in UNION (where the unsectioned neighbour keeps the
366 // whole edge 21). Anchor a boundary endpoint to a same-side image only when
367 // they are the SAME physical vertex (3D-coincident within the weld floor).
368 // Hatch BREP_BOUNDARY_VERTEX_ANCHOR=0 restores the pre-fix behaviour.
369 let boundary_vertex_anchor_off =
370 std::env::var("BREP_BOUNDARY_VERTEX_ANCHOR").as_deref() == Ok("0");
371 let boundary_side = |point: Vec2| {
372 let distances = [
373 (point.x - u_domain[0]).abs(),
374 (point.x - u_domain[1]).abs(),
375 (point.y - v_domain[0]).abs(),
376 (point.y - v_domain[1]).abs(),
377 ];
378 let index = distances
379 .iter()
380 .enumerate()
381 .min_by(|a, b| a.1.total_cmp(b.1))
382 .map(|entry| entry.0)
383 .unwrap();
384 (distances[index] <= arrangement_tolerance * 50.0).then_some(index)
385 };
386
387 let mut add_chain = |mut points: Vec<Vec2>,
388 source: ChainSource,
389 vertex_start_key: String,
390 vertex_start_point: Vec3,
391 vertex_end_key: String,
392 vertex_end_point: Vec3,
393 is_boundary: bool|
394 -> Result<(), String> {
395 for (at, key, point3) in [
396 (0usize, vertex_start_key, vertex_start_point),
397 (points.len() - 1, vertex_end_key, vertex_end_point),
398 ] {
399 let point = points[at];
400 let images = vertex_images.entry(key).or_default();
401 let canonical = images
402 .iter()
403 .copied()
404 .find(|image| image.sub(point).length() <= snap_threshold)
405 .or_else(|| {
406 endpoint_images
407 .iter()
408 .find(|(candidate3, candidate)| {
409 candidate3.sub(point3).length() <= 1e-5
410 && candidate.sub(point).length() <= arrangement_tolerance * 100.0
411 })
412 .map(|candidate| candidate.1)
413 })
414 .or_else(|| {
415 boundary_side(point).and_then(|side| {
416 boundary_images
417 .iter()
418 .find(|(candidate, candidate3)| {
419 boundary_side(*candidate) == Some(side)
420 && candidate.sub(point).length()
421 <= arrangement_tolerance * 100.0
422 // Boundary endpoints only merge to a same-side
423 // image when 3D-coincident (same physical
424 // vertex); cut endpoints keep the graze bridge.
425 && (boundary_vertex_anchor_off
426 || !is_boundary
427 || candidate3.sub(point3).length() <= 1e-5)
428 })
429 .map(|(candidate, _)| *candidate)
430 })
431 });
432 if let Some(canonical) = canonical {
433 points[at] = canonical;
434 images.push(canonical);
435 } else {
436 images.push(point);
437 endpoint_images.push((point3, point));
438 if is_boundary && boundary_side(point).is_some() {
439 boundary_images.push((point, point3));
440 }
441 }
442 }
443 if points.len() > 2 {
444 let start = points[0];
445 let end = points[points.len() - 1];
446 let mut kept = vec![start];
447 kept.extend(points[1..points.len() - 1].iter().copied().filter(|point| {
448 point.sub(start).length() > junction_radius
449 && point.sub(end).length() > junction_radius
450 }));
451 kept.push(end);
452 points = kept;
453 }
454 let chain_index = chains.len();
455 let chain_segments = points
456 .windows(2)
457 .map(|pair| (pair[0], pair[1]))
458 .collect::<Vec<_>>();
459 for pair in points.windows(2) {
460 segments.push(Segment2 {
461 a: pair[0],
462 b: pair[1],
463 tag: json!({ "chain": chain_index }),
464 });
465 }
466 chains.push(Chain {
467 source,
468 count: points.len() - 1,
469 start: points[0],
470 end: points[points.len() - 1],
471 segments: chain_segments,
472 });
473 Ok(())
474 };
475
476 let _tc = web_time::Instant::now();
477 // SEAM-IMAGE LOOPS: per-loop u-control-hulls, computed once for the
478 // straddling-hole gate below (does any OTHER loop span the full closed-u
479 // domain — the rectangle-with-seam outer that closes the strip walls).
480 let loop_hull_u: Vec<[f64; 2]> = face
481 .loops
482 .iter()
483 .map(|loop_record| {
484 let mut hull = [f64::INFINITY, f64::NEG_INFINITY];
485 for coedge in &loop_record.coedges {
486 for control in &coedge.pcurve.control_points {
487 if control.w.abs() <= 1e-300 {
488 continue;
489 }
490 hull[0] = hull[0].min(control.x / control.w);
491 hull[1] = hull[1].max(control.x / control.w);
492 }
493 }
494 hull
495 })
496 .collect();
497 // Signed area of the face's full-u-span loop (the rectangle-with-seam
498 // outer), recorded as it is processed: it defines the chart's material
499 // orientation, so a HOLE is any loop winding OPPOSITE to it (the absolute
500 // sign is chart-dependent — mirrored charts flip both).
501 let mut full_span_loop_area: Option<f64> = None;
502 // Post-rebase boundary polyline segments (incl. seam-image copies), kept
503 // for the sag-refinement boundary-crossing parity guard on cut chains.
504 let mut boundary_segments: Vec<(Vec2, Vec2)> = Vec::new();
505 for (loop_index, loop_record) in face.loops.iter().enumerate() {
506 // SEAM-HOP UNWRAP: a loop whose pcurves are drawn IN-DOMAIN but hop
507 // the seam between consecutive coedges (t91's face 675: the boundary
508 // jumps u=1→0 at v≈0.967 and back at v≈0.453) is incoherent in the
509 // flat chart — its cut chains mint fine and then dangle against the
510 // hopping boundary, cycle extraction fails, and the face drops
511 // wholesale (the trial-35 zero-fragment signature, third variant).
512 // Unwrap onto the covering plane with the SAME per-coedge offsets the
513 // mass integrator / tessellator / seam-band classifier use
514 // (`loop_seam_offsets`); a continuous loop gets all-zero offsets and
515 // is untouched (helmet-class unwrapped sources, native seam-edge
516 // loops, plain faces). Composes with the band re-base above: offsets
517 // normalize the boundary FIRST, after which the re-base's
518 // below-cutoff boundary shifts are no-ops and only its cut-chain
519 // shifts still apply. Escape hatch: BREP_LOOP_UNWRAP=0.
520 let closed = face.surface.closed_directions()?;
521 let offsets = if (closed.0 || closed.1)
522 && std::env::var("BREP_LOOP_UNWRAP").as_deref() != Ok("0")
523 {
524 crate::topology::loop_seam_offsets(
525 &loop_record.coedges,
526 closed.0,
527 closed.1,
528 u_domain[1] - u_domain[0],
529 v_domain[1] - v_domain[0],
530 )?
531 } else {
532 vec![[0.0, 0.0]; loop_record.coedges.len()]
533 };
534 let mut sampled: Vec<(usize, Vec<Vec2>, NurbsCurve, u64, u64)> =
535 Vec::with_capacity(loop_record.coedges.len());
536 for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
537 let edge = edge_for(solid, coedge.edge_id)?;
538 let (start_vertex, end_vertex) = if coedge.forward {
539 (edge.start_vertex_id, edge.end_vertex_id)
540 } else {
541 (edge.end_vertex_id, edge.start_vertex_id)
542 };
543 let offset = offsets
544 .get(coedge_index)
545 .copied()
546 .unwrap_or([0.0, 0.0]);
547 let points: Vec<Vec2> =
548 refine_near_hints(&sample_chain(&coedge.pcurve)?, &coedge.pcurve, &cut_hints)?
549 .into_iter()
550 .map(|point| {
551 rebase_point(Vec2 {
552 x: point.x + offset[0],
553 y: point.y + offset[1],
554 })
555 })
556 .collect();
557 let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
558 let curve = if coedge.forward {
559 curve
560 } else {
561 curve.reversed()?
562 };
563 sampled.push((coedge_index, points, curve, start_vertex, end_vertex));
564 }
565 // SEAM-IMAGE for a STRADDLING HOLE loop: an inner (hole) loop whose
566 // unwrapped chains poke past a closed-u domain wall exists at only ONE
567 // period image in the arrangement strip, so the strip's OTHER side
568 // never sees it — the seam-adjacent region there keeps a boundary
569 // running straight through the hole opening (t181/00000312#p7: face
570 // 519's decagon hole unwraps to u∈[−0.039,0.039]; the region left of
571 // the seam used the FULL seam edge 475 while the right region split at
572 // the hole crossings; the hole's far-half rim edges stranded one-use
573 // and an 18mm derived chord bridged the gap). Insert a duplicate of
574 // the loop's chains shifted one period toward the strip's other side,
575 // so BOTH walls are split by the hole and the half-hole phantom
576 // regions (rejected by the cross-frame containment) carve it out.
577 // STRUCTURAL GATE (all required): u-closed face; ≥2 loops; the loop's
578 // unwrapped span crosses a u-wall by > junction slack; loop width ≤
579 // half the period (a compact hole, never a rim/horizon); winding
580 // OPPOSITE to the full-span outer loop (hole orientation relative to
581 // the chart — an outer loop copy would double-cover material); and
582 // some OTHER loop's control hull spans the full u-domain (the
583 // seam-carrying rectangle exists). Escape hatch:
584 // BREP_SEAM_IMAGE_LOOPS=0.
585 let image_shift: Option<f64> = {
586 let span = u_domain[1] - u_domain[0];
587 let slack = arrangement_tolerance * 50.0;
588 if !closed.0
589 || face.loops.len() < 2
590 || span <= 0.0
591 || std::env::var("BREP_SEAM_IMAGE_LOOPS").as_deref() == Ok("0")
592 {
593 None
594 } else {
595 let mut min_u = f64::INFINITY;
596 let mut max_u = f64::NEG_INFINITY;
597 let mut area = 0.0;
598 let mut flat: Vec<Vec2> = sampled
599 .iter()
600 .flat_map(|(_, points, ..)| points.iter().copied())
601 .collect();
602 if flat.len() < 3 {
603 flat.clear();
604 }
605 for (index, point) in flat.iter().enumerate() {
606 min_u = min_u.min(point.x);
607 max_u = max_u.max(point.x);
608 let next = flat[(index + 1) % flat.len()];
609 area += point.x * next.y - next.x * point.y;
610 }
611 // Full-span discrimination must use the UNWRAPPED width: a
612 // straddling hole's raw control hull spans the whole domain
613 // (its halves are stored at both seam sides), but its
614 // unwrapped chains are compact; only the true outer rectangle
615 // stays period-wide after unwrapping.
616 let spans_full_domain =
617 !flat.is_empty() && max_u - min_u >= span * (1.0 - 1e-6);
618 if spans_full_domain && full_span_loop_area.is_none() {
619 full_span_loop_area = Some(area);
620 }
621 let straddles_low = min_u < u_domain[0] - slack;
622 let straddles_high = max_u > u_domain[1] + slack;
623 let compact = max_u - min_u <= 0.5 * span;
624 let is_hole = full_span_loop_area
625 .map(|outer| !spans_full_domain && area * outer < 0.0)
626 .unwrap_or(false);
627 let has_full_span_other = loop_hull_u.iter().enumerate().any(|(other, hull)| {
628 other != loop_index
629 && hull[0] <= u_domain[0] + 1e-6 * span
630 && hull[1] >= u_domain[1] - 1e-6 * span
631 });
632 if compact && is_hole && has_full_span_other && (straddles_low ^ straddles_high) {
633 if std::env::var("BREP_DEBUG_FRAG")
634 .map(|value| value == face.id.to_string())
635 .unwrap_or(false)
636 {
637 eprintln!(
638 " seam-image: face {} loop {loop_index} straddles u-wall (u=[{min_u:.6},{max_u:.6}]) — inserting {} period image",
639 face.id,
640 if straddles_low { "+1" } else { "-1" }
641 );
642 }
643 Some(if straddles_low { span } else { -span })
644 } else {
645 None
646 }
647 }
648 };
649 for (coedge_index, points, curve, start_vertex, end_vertex) in sampled {
650 let coedge = &loop_record.coedges[coedge_index];
651 boundary_segments.extend(points.windows(2).map(|pair| (pair[0], pair[1])));
652 add_chain(
653 points.clone(),
654 ChainSource::Boundary {
655 coedge: coedge.clone(),
656 curve: curve.clone(),
657 },
658 format!("b:{operand}:{start_vertex}"),
659 vertex_point(solid, start_vertex)?,
660 format!("b:{operand}:{end_vertex}"),
661 vertex_point(solid, end_vertex)?,
662 true,
663 )?;
664 if let Some(shift) = image_shift {
665 let shifted: Vec<Vec2> = points
666 .iter()
667 .map(|point| Vec2 {
668 x: point.x + shift,
669 y: point.y,
670 })
671 .collect();
672 boundary_segments
673 .extend(shifted.windows(2).map(|pair| (pair[0], pair[1])));
674 add_chain(
675 shifted,
676 ChainSource::Boundary {
677 coedge: coedge.clone(),
678 curve,
679 },
680 format!("b:{operand}:{start_vertex}"),
681 vertex_point(solid, start_vertex)?,
682 format!("b:{operand}:{end_vertex}"),
683 vertex_point(solid, end_vertex)?,
684 true,
685 )?;
686 }
687 }
688 }
689 // CUT-CHAIN CLAMP BOUND: on a CLOSED direction the face's boundary band may
690 // legitimately extend past the surface domain — a seam-straddling face is
691 // arranged in UNWRAPPED coordinates (helmet Face_15's boundary chains run
692 // u∈[-0.178, 0.295] on a [0,1]-domain surface). Clamping a cut pcurve to
693 // the surface domain there tears it off the band: the branch-preserved
694 // truncation bridge collapsed to a count=1 sliver hugging u=0 and the face
695 // never split. Widen the clamp bound to domain ∪ trim-pcurve control hulls
696 // (the hulls bound the boundary chains); a cut already inside the domain is
697 // untouched, and OPEN directions keep the plain domain clamp. Escape hatch
698 // BREP_FRAG_ENV_CLAMP=0.
699 let (clamp_u, clamp_v) = {
700 let mut clamp_u = u_domain;
701 let mut clamp_v = v_domain;
702 let (closed_u, closed_v) = face.surface.closed_directions()?;
703 if (closed_u || closed_v) && std::env::var("BREP_FRAG_ENV_CLAMP").as_deref() != Ok("0") {
704 for loop_record in &face.loops {
705 for coedge in &loop_record.coedges {
706 for point in &coedge.pcurve.control_points {
707 if point.w.abs() <= 1e-300 {
708 continue;
709 }
710 if closed_u {
711 clamp_u[0] = clamp_u[0].min(point.x / point.w);
712 clamp_u[1] = clamp_u[1].max(point.x / point.w);
713 }
714 if closed_v {
715 clamp_v[0] = clamp_v[0].min(point.y / point.w);
716 clamp_v[1] = clamp_v[1].max(point.y / point.w);
717 }
718 }
719 }
720 }
721 }
722 (clamp_u, clamp_v)
723 };
724 for piece_id in active_pieces {
725 let piece = pieces_by_id[&piece_id];
726 let pcurve = pcurve_for(piece, face_key)?.clone();
727 // SAG-BOUNDED CUT-CHAIN FIDELITY (t70 split-set asynchrony class).
728 // `sample_chain`'s uniform sampling is knot-driven, so the SAME
729 // imprint piece gets a 16-segment polyline on one support face and a
730 // 92-segment one on the other (the counts follow each face's pcurve
731 // knot structure, not the curvature). A coarse chord can then sag
732 // MILLIMETRE-scale away from the true pcurve and invade a trim
733 // region the true curve clears — t70: piece 55's chord on face
734 // 1:405 sagged ~3e-3 into a hole corner the true pcurve misses by
735 // 1.3e-3, so the arrangement carved phantom micro-junctions on that
736 // face only, the two owners' split sets diverged (whole Imprint
737 // edge vs a 4-piece Derived chain of the same locus), and every op
738 // failed with one-use edges. Refining every cut chain until its
739 // chords hug the true pcurve within the arrangement's own tolerance
740 // makes both owners' polylines faithful to the ONE shared curve, so
741 // their junction sets agree structurally (no proximity decisions
742 // anywhere). Escape hatch: BREP_CHAIN_SAG_REFINE=0.
743 let (mut points, original_flags) = sag_refine_chain(
744 &pcurve,
745 sample_chain(&pcurve)?,
746 arrangement_tolerance,
747 )?;
748 for point in &mut points {
749 if point.x < clamp_u[0] {
750 point.x = clamp_u[0] + arrangement_tolerance * 10.0;
751 } else if point.x > clamp_u[1] {
752 point.x = clamp_u[1] - arrangement_tolerance * 10.0;
753 }
754 if point.y < clamp_v[0] {
755 point.y = clamp_v[0] + arrangement_tolerance * 10.0;
756 } else if point.y > clamp_v[1] {
757 point.y = clamp_v[1] - arrangement_tolerance * 10.0;
758 }
759 }
760 let snap_endpoint_to_domain = |point: Vec2| {
761 let candidates = [
762 (
763 (point.x - u_domain[0]).abs(),
764 Vec2 {
765 x: u_domain[0],
766 y: point.y,
767 },
768 ),
769 (
770 (point.x - u_domain[1]).abs(),
771 Vec2 {
772 x: u_domain[1],
773 y: point.y,
774 },
775 ),
776 (
777 (point.y - v_domain[0]).abs(),
778 Vec2 {
779 x: point.x,
780 y: v_domain[0],
781 },
782 ),
783 (
784 (point.y - v_domain[1]).abs(),
785 Vec2 {
786 x: point.x,
787 y: v_domain[1],
788 },
789 ),
790 ];
791 let nearest = candidates
792 .into_iter()
793 .min_by(|first, second| first.0.total_cmp(&second.0))
794 .unwrap();
795 if nearest.0 <= arrangement_tolerance * 50.0 {
796 nearest.1
797 } else {
798 point
799 }
800 };
801 let last = points.len() - 1;
802 points[0] = snap_endpoint_to_domain(points[0]);
803 points[last] = snap_endpoint_to_domain(points[last]);
804 for point in &mut points {
805 *point = rebase_point(*point);
806 }
807 // Guard runs on the FINAL (clamped/snapped/rebased) coordinates — the
808 // same frame the boundary chains were sampled into — so crossing
809 // parity is measured exactly where the arrangement would see it.
810 points = enforce_boundary_crossing_parity(
811 points,
812 &original_flags,
813 &boundary_segments,
814 arrangement_tolerance,
815 std::env::var("BREP_DEBUG_FRAG")
816 .map(|value| value == face.id.to_string())
817 .unwrap_or(false),
818 face.id,
819 );
820 let start_point = index
821 .vertex_points
822 .get(&piece.start_vertex_id)
823 .copied()
824 .ok_or_else(|| "fragment_face: missing imprint start vertex".to_string())?;
825 let end_point = index
826 .vertex_points
827 .get(&piece.end_vertex_id)
828 .copied()
829 .ok_or_else(|| "fragment_face: missing imprint end vertex".to_string())?;
830 add_chain(
831 points,
832 ChainSource::Cut {
833 piece_id,
834 pcurve,
835 curve: trimmed_curve(&piece.curve, piece.t0, piece.t1)?,
836 shared_ring: piece.shared_edge.filter(|_| {
837 piece
838 .curve
839 .evaluate(piece.t0)
840 .ok()
841 .zip(piece.curve.evaluate(piece.t1).ok())
842 .is_some_and(|(a, b)| a.sub(b).length() <= 1e-6)
843 }),
844 },
845 format!("i:{}", piece.start_vertex_id),
846 start_point,
847 format!("i:{}", piece.end_vertex_id),
848 end_point,
849 false,
850 )?;
851 }
852 drop(add_chain);
853
854 // POLE-GAP BRIDGE (single-loop periodic cap; corpus fixture 25 cube-pierce).
855 // A face closed in one parameter direction can carry a DEGENERATE pole
856 // iso-line — a whole domain-edge row of one parameter collapsing to a
857 // single 3D point (the dome apex v2 at v=0 here). The imported trim
858 // represents the pole with a partial arc plus a degenerate self-edge that
859 // stop SHORT of the opposite seam foot, so the outer boundary is OPEN in
860 // the flat UV chart at the pole: the two seam feet sit a FRACTION of a
861 // period apart (never a whole period), so no covering-strip period-image
862 // can ever join them — this case is structurally outside the seam-image
863 // loops machinery above. When a section cut then splits the cap,
864 // `arrange_segments` closes only the lens region that avoids the pole and
865 // DROPS the seam-and-pole-spanning outside region: the cap fragments to 1
866 // not 2, its section + outer-rim edges strand one-use (union/subtract fail
867 // `S=1 genus=1`), while intersect keeps the lens and succeeds. Close the
868 // boundary in-chart: pair the two dangling (odd-incidence) boundary
869 // endpoints that are UV images of the SAME solid vertex (structural
870 // identity via `vertex_images`, never proximity clustering), VERIFY the
871 // straight UV connector between them is degenerate (every sample collapses
872 // to one 3D point — a genuine pole, not a real seam that would cross
873 // material), and add a bridge chain that REUSES an existing degenerate
874 // pole edge as its source. The reused edge is 3D-zero-length, so the
875 // minted coedge is s==e and exempt from the two-use rule — no new edge is
876 // introduced (the zero-regression reuse pattern), and the outside fragment
877 // reproduces the original cap loop's own pole/seam structure. A
878 // cleanly-closing boundary has no odd node and is byte-identical untouched;
879 // a genuine (non-degenerate) seam gap fails the degeneracy check and is
880 // left alone (the safe direction). Escape hatch: BREP_POLE_GAP_BRIDGE=0.
881 let pole_gap_closed = face.surface.closed_directions()?;
882 if (pole_gap_closed.0 || pole_gap_closed.1)
883 && std::env::var("BREP_POLE_GAP_BRIDGE").as_deref() != Ok("0")
884 {
885 // A degenerate pole iso-line only exists on a surface closed in the
886 // seam direction; a non-periodic face never reaches this block and is
887 // byte-identical untouched.
888 //
889 // `add_chain` canonicalizes shared endpoints to a bit-identical `Vec2`,
890 // so a TIGHT epsilon groups exactly the coincident nodes and never
891 // over-merges two distinct nearby vertices (which would corrupt the
892 // incidence parity on a small face).
893 let node_tol = arrangement_tolerance * 10.0;
894 // Incidence over BOUNDARY chain endpoints only — the pole gap is a
895 // property of the trim boundary, and cut chains anchor to it elsewhere.
896 let mut nodes: Vec<(Vec2, usize)> = Vec::new();
897 for chain in &chains {
898 if !matches!(chain.source, ChainSource::Boundary { .. }) {
899 continue;
900 }
901 for point in [chain.start, chain.end] {
902 if let Some(entry) = nodes.iter_mut().find(|(q, _)| q.sub(point).length() <= node_tol)
903 {
904 entry.1 += 1;
905 } else {
906 nodes.push((point, 1));
907 }
908 }
909 }
910 // Odd incidence => the boundary is UV-open at that endpoint.
911 let odd: Vec<Vec2> = nodes
912 .iter()
913 .filter(|(_, count)| count % 2 == 1)
914 .map(|(point, _)| *point)
915 .collect();
916 // Each odd node's owning solid vertex: the canonical image lists in
917 // `vertex_images` are keyed "b:{operand}:{vertex_id}".
918 let vertex_of = |point: Vec2| -> Option<u64> {
919 vertex_images.iter().find_map(|(key, images)| {
920 images
921 .iter()
922 .any(|image| image.sub(point).length() <= node_tol)
923 .then(|| key.rsplit(':').next().and_then(|id| id.parse::<u64>().ok()))
924 .flatten()
925 })
926 };
927 let mut by_vertex: HashMap<u64, Vec<Vec2>> = HashMap::default();
928 for point in &odd {
929 if let Some(vertex_id) = vertex_of(*point) {
930 by_vertex.entry(vertex_id).or_default().push(*point);
931 }
932 }
933 // Forward-evaluate only (the projector is a poisoned oracle; evaluate
934 // is exact). A degenerate 3D curve collapses to a point over its span.
935 let curve_degenerate = |curve: &NurbsCurve| -> bool {
936 let Ok(domain) = curve.domain() else {
937 return false;
938 };
939 let Ok(base) = curve.evaluate(domain[0]) else {
940 return false;
941 };
942 [0.5, 1.0].iter().all(|fraction| {
943 curve
944 .evaluate(domain[0] + (domain[1] - domain[0]) * fraction)
945 .map(|point| point.sub(base).length() <= 1e-5)
946 .unwrap_or(false)
947 })
948 };
949 for (vertex_id, images) in by_vertex {
950 // A pole slit unclosed in exactly one place has EXACTLY two dangling
951 // images of its apex vertex; anything else is not this class.
952 if images.len() != 2 {
953 continue;
954 }
955 let (a, b) = (images[0], images[1]);
956 // The straight UV connector must be a degenerate iso-line: every
957 // sample collapses to one 3D point. This is the gate that keeps a
958 // real seam (whose connector crosses material) from being bridged.
959 let mut base: Option<Vec3> = None;
960 let mut degenerate = true;
961 for step in 0..=8 {
962 let fraction = step as f64 / 8.0;
963 let uv = a.add(b.sub(a).scale(fraction));
964 match face.surface.evaluate(uv.x, uv.y) {
965 Ok(point) => match base {
966 None => base = Some(point),
967 Some(reference) => {
968 if point.sub(reference).length() > 1e-5 {
969 degenerate = false;
970 break;
971 }
972 }
973 },
974 Err(_) => {
975 degenerate = false;
976 break;
977 }
978 }
979 }
980 if !degenerate {
981 continue;
982 }
983 // Reuse an existing degenerate pole edge's source (its 3D curve is a
984 // point, so the minted coedge is s==e and validation-exempt).
985 let Some(source) = chains.iter().find_map(|chain| match &chain.source {
986 ChainSource::Boundary { coedge, curve }
987 if chain.start.sub(chain.end).length() <= node_tol
988 && chain.count <= 1
989 && (chain.start.sub(a).length() <= node_tol
990 || chain.start.sub(b).length() <= node_tol)
991 && curve_degenerate(curve) =>
992 {
993 Some(ChainSource::Boundary {
994 coedge: coedge.clone(),
995 curve: curve.clone(),
996 })
997 }
998 _ => None,
999 }) else {
1000 continue;
1001 };
1002 let chain_index = chains.len();
1003 segments.push(Segment2 {
1004 a,
1005 b,
1006 tag: json!({ "chain": chain_index }),
1007 });
1008 chains.push(Chain {
1009 source,
1010 count: 1,
1011 start: a,
1012 end: b,
1013 segments: vec![(a, b)],
1014 });
1015 boundary_segments.push((a, b));
1016 if std::env::var("BREP_DEBUG_FRAG")
1017 .map(|value| value == face.id.to_string())
1018 .unwrap_or(false)
1019 {
1020 eprintln!(
1021 " pole-gap bridge: face {} vertex {vertex_id} joins \
1022 ({:.6},{:.6})->({:.6},{:.6}) along a degenerate iso-line",
1023 face.id, a.x, a.y, b.x, b.y
1024 );
1025 }
1026 }
1027 }
1028
1029 // A cut chain that coincides with a boundary chain cannot split the
1030 // interior — the face's own edge already bounds it there. Worse, the
1031 // duplicate overlapping segments corrupt arrangement cycle extraction
1032 // and silently swallow every region that touches the overlap (seen when
1033 // endpoint polishing snaps a face/face intersection line onto a
1034 // near-coincident boundary edge of the same face). Drop such cuts
1035 // before arranging.
1036 let coincidence_tolerance = arrangement_tolerance * 50.0;
1037 let point_segment_distance = |point: Vec2, a: Vec2, b: Vec2| -> f64 {
1038 let segment = b.sub(a);
1039 let length_squared = segment.dot(segment);
1040 if length_squared <= 1e-30 {
1041 return point.sub(a).length();
1042 }
1043 let parameter = (point.sub(a).dot(segment) / length_squared).clamp(0.0, 1.0);
1044 point.sub(a.add(segment.scale(parameter))).length()
1045 };
1046 let near_chain = |point: Vec2, chain: &Chain, tolerance: f64| {
1047 chain
1048 .segments
1049 .iter()
1050 .any(|(a, b)| point_segment_distance(point, *a, *b) <= tolerance)
1051 };
1052 // A cut coincides with a boundary only if its INTERIOR follows the same
1053 // path, not merely its endpoints. A single-segment straight cut whose two
1054 // endpoints happen to land on a CURVED boundary edge's endpoints (a
1055 // cap∩cap intersection chord spanning a curved rim arc — the missing
1056 // cone-cap split behind the curved-primitive open-edge booleans) shares
1057 // only its endpoints and cuts clean across the interior; dropping it as a
1058 // duplicate loses the region it carves. So also require the segment
1059 // MIDPOINTS to lie near the boundary. The midpoint test runs at a looser
1060 // threshold than the endpoint match: a genuinely-coincident curved cut's
1061 // polyline midpoints carry their own chord sag (order 1× tolerance), which
1062 // must still count as near, while a chord spanning a curved arc departs by
1063 // the arc's sagitta (orders of magnitude larger) and is correctly kept. On
1064 // the box path both edges are straight, so two shared endpoints force the
1065 // midpoint onto the boundary and the drop is unchanged.
1066 let midpoint_tolerance = coincidence_tolerance * 8.0;
1067 // CORNER-CHORD VETO. The endpoint match above admits a cut whose endpoint
1068 // sits up to `coincidence_tolerance` (50x arr_tol) from the boundary
1069 // chain's endpoint. When a section threads through a MODEL CORNER where two
1070 // boundary edges (legs) meet at a shared vertex, the section arm on the
1071 // sliver face between them is the HYPOTENUSE of a tiny right triangle whose
1072 // short leg can be well under that slack (t634: the torus crosses a 3-face
1073 // corner of 00000327; on the sliver face the arm's start is 31x arr_tol
1074 // from the matched leg's start, and the hypotenuse midpoint is ~15x arr_tol
1075 // off the leg — both under the 50x/400x tolerances). The old test dropped
1076 // that arm as a "duplicate" of one leg, so the tiny corner region was never
1077 // carved on the sliver face while the mate operand kept the arm -> the
1078 // shared section edge went one-use (invalid genus). A genuine corner chord
1079 // is distinguishable STRUCTURALLY, not by tolerance: its endpoint coincides
1080 // (at NODE scale = the arrangement's own find_node snap) with a real,
1081 // DISTINCT boundary vertex — proof it terminates at a 0-cell of its own,
1082 // spanning two legs, rather than retracing this one edge. A true retrace
1083 // (box duplicate, cap-rim chord) has endpoints AT the matched chain's ends
1084 // (gap ~0), so the veto never triggers for it. Hatch restores the old drop.
1085 let corner_veto_enabled = std::env::var("BREP_DUP_DROP_CORNER_VETO")
1086 .map(|value| value != "0")
1087 .unwrap_or(true);
1088 let node_tolerance = arrangement_tolerance;
1089 let terminates_at_distinct_boundary_vertex = |cut_end: Vec2, matched_end: Vec2| -> bool {
1090 cut_end.sub(matched_end).length() > node_tolerance
1091 && chains.iter().any(|other| {
1092 matches!(other.source, ChainSource::Boundary { .. })
1093 && (other.start.sub(cut_end).length() <= node_tolerance
1094 || other.end.sub(cut_end).length() <= node_tolerance)
1095 })
1096 };
1097 let debug_drop = std::env::var("BREP_DEBUG_FRAG")
1098 .map(|value| value == face.id.to_string())
1099 .unwrap_or(false);
1100 // MICRO-BOUNDARY SKIP (t316). A boundary chain SHORTER than the coincidence
1101 // tolerance cannot serve as a duplication reference: its two endpoints are
1102 // indistinguishable at this tolerance, so the orientation-agnostic endpoint
1103 // match collapses and a cut that merely touches ONE of its ends spuriously
1104 // "matches" BOTH. That wrongly drops a junction-bridge micro-cut spanning a
1105 // weld-scale vertex cluster (t316: cut p38, the ~0.004mm bridge from a
1106 // section terminus C to the plate-rim node C', gets dropped against e99 — a
1107 // ~0.008mm rim segment shorter than the 0.07mm coincidence tolerance —
1108 // stranding the whole b-poke bottom-cap sliver one-use). The genuine target
1109 // (the tangent-wall cap section, coincident with a full-length box edge ≫
1110 // tolerance) is untouched. Independent of the corner-chord veto below: this
1111 // guard disqualifies a sub-tolerance BOUNDARY as a reference, that one spares
1112 // a corner-spanning CUT. Hatch `BREP_DUP_CUT_MICRO_BOUNDARY=0` restores the
1113 // old, length-agnostic reference test.
1114 let micro_boundary_skip = std::env::var("BREP_DUP_CUT_MICRO_BOUNDARY").as_deref() != Ok("0");
1115 let boundary_extent = |boundary: &Chain| -> f64 {
1116 boundary
1117 .segments
1118 .iter()
1119 .map(|(a, b)| b.sub(*a).length())
1120 .sum()
1121 };
1122 let mut dropped_chains = HashSet::default();
1123 for (index, chain) in chains.iter().enumerate() {
1124 if !matches!(chain.source, ChainSource::Cut { .. }) {
1125 continue;
1126 }
1127 let mut matched_boundary: Option<usize> = None;
1128 for (boundary_index, boundary) in chains.iter().enumerate() {
1129 if !matches!(boundary.source, ChainSource::Boundary { .. }) {
1130 continue;
1131 }
1132 // t316: a sub-tolerance micro-boundary cannot be a duplication
1133 // reference — its endpoints collapse at this tolerance, so a cut that
1134 // merely touches one end spuriously matches both. Skip it as a
1135 // candidate (never selectable as `matched_boundary`).
1136 if micro_boundary_skip && boundary_extent(boundary) <= coincidence_tolerance {
1137 continue;
1138 }
1139 let forward = boundary.start.sub(chain.start).length() <= coincidence_tolerance
1140 && boundary.end.sub(chain.end).length() <= coincidence_tolerance;
1141 let reversed = boundary.start.sub(chain.end).length() <= coincidence_tolerance
1142 && boundary.end.sub(chain.start).length() <= coincidence_tolerance;
1143 if !(forward || reversed) {
1144 continue;
1145 }
1146 let interior_coincident = chain.segments.iter().all(|(a, b)| {
1147 near_chain(*a, boundary, coincidence_tolerance)
1148 && near_chain(*b, boundary, coincidence_tolerance)
1149 && near_chain(a.add(*b).scale(0.5), boundary, midpoint_tolerance)
1150 });
1151 if !interior_coincident {
1152 continue;
1153 }
1154 if corner_veto_enabled {
1155 // Pair each cut endpoint with the boundary endpoint it matched.
1156 let (bnd_for_start, bnd_for_end) = if forward {
1157 (boundary.start, boundary.end)
1158 } else {
1159 (boundary.end, boundary.start)
1160 };
1161 if terminates_at_distinct_boundary_vertex(chain.start, bnd_for_start)
1162 || terminates_at_distinct_boundary_vertex(chain.end, bnd_for_end)
1163 {
1164 continue;
1165 }
1166 }
1167 matched_boundary = Some(boundary_index);
1168 break;
1169 }
1170 if let Some(boundary_index) = matched_boundary {
1171 if debug_drop {
1172 let boundary = &chains[boundary_index];
1173 eprintln!(
1174 " drop cut chain {} as duplicate of boundary chain {} (endpoint gaps {:.3e}/{:.3e})",
1175 index,
1176 boundary_index,
1177 boundary.start.sub(chain.start).length(),
1178 boundary.end.sub(chain.end).length(),
1179 );
1180 }
1181 dropped_chains.insert(index);
1182 }
1183 }
1184 if !dropped_chains.is_empty() {
1185 segments.retain(|segment| {
1186 segment.tag["chain"]
1187 .as_u64()
1188 .map(|chain| !dropped_chains.contains(&(chain as usize)))
1189 .unwrap_or(true)
1190 });
1191 }
1192
1193 let debug = std::env::var("BREP_DEBUG_FRAG")
1194 .map(|value| value == face.id.to_string())
1195 .unwrap_or(false);
1196 if debug {
1197 eprintln!(
1198 "fragment_face {}: domain u={:?} v={:?} arr_tol={:.3e} chains={} segments={}",
1199 face.id,
1200 u_domain,
1201 v_domain,
1202 arrangement_tolerance,
1203 chains.len(),
1204 segments.len()
1205 );
1206 for (index, chain) in chains.iter().enumerate() {
1207 let kind = match &chain.source {
1208 ChainSource::Boundary { coedge, .. } => format!("boundary e{}", coedge.edge_id),
1209 ChainSource::Cut { piece_id, .. } => format!("cut p{piece_id}"),
1210 };
1211 eprintln!(
1212 " chain {index} {kind} count={} start=({:.9},{:.9}) end=({:.9},{:.9})",
1213 chain.count, chain.start.x, chain.start.y, chain.end.x, chain.end.y
1214 );
1215 }
1216 }
1217 // TRIPLE-POINT CUT-CHAIN BRIDGE (imported-geometry corner rescue).
1218 // Two SSI cut curves that meet at a triple point (two faces of one operand
1219 // both crossing a single face of the other) can be marched to endpoints that
1220 // DISAGREE by more than arr_tol when the operand's shared edge carries an
1221 // imported edge<->vertex gap — the imprint then mints TWO vertices for the
1222 // one corner, so the cut chain is BROKEN there. A broken cut cannot separate
1223 // the domain: the arrangement returns one region covering the whole face, the
1224 // face is classified by a single test point and wrongly dropped, and its
1225 // neighbours' shared SSI edges are stranded one-use (non-integral genus).
1226 // Reconnect a pair of cut-chain endpoints that BOTH dangle in the interior
1227 // (each neither on a domain boundary nor meeting any other chain endpoint)
1228 // and whose chains are EACH anchored at their other end, by snapping them to
1229 // their midpoint. Gated on the dangling-anchored-pair precondition, which
1230 // only a broken cut produces, so a face whose cuts already connect (every
1231 // currently-succeeding fragmentation) is never touched.
1232 if chains.iter().any(|c| matches!(c.source, ChainSource::Cut { .. })) {
1233 let endpoints: Vec<(usize, bool, Vec2)> = chains
1234 .iter()
1235 .enumerate()
1236 .flat_map(|(i, c)| [(i, false, c.start), (i, true, c.end)])
1237 .collect();
1238 let anchored = |chain: usize, point: Vec2| -> bool {
1239 boundary_side(point).is_some()
1240 || endpoints.iter().any(|(other_chain, _, other_point)| {
1241 *other_chain != chain
1242 && other_point.sub(point).length() <= junction_radius
1243 })
1244 };
1245 // Interior cut endpoints with an anchored far end are the loose ends of
1246 // an almost-through cut. Only these are bridge candidates.
1247 //
1248 // OVERSHOOT-STUB EXCLUSION (offset-shell triple junctions). A pairwise
1249 // section between offset carriers is clipped to the offset IMAGE of the
1250 // source trims, which can extend PAST the true junction with a third
1251 // carrier's section instead of stopping at it (O.S14: the plane×cylinder
1252 // line runs to the source cone∩cylinder circle image at y=100/13 while
1253 // the plane×cone section crosses it ~0.3 earlier at the real offset
1254 // junction). Such a chain is already CONNECTED into the cut graph:
1255 // arrange_segments splits both chains at the crossing node and its
1256 // degree-1 pruning drops the overshoot stub beyond it, and build_loop
1257 // emits the surviving partial runs as Derived sub-curves. The stub's
1258 // dangling endpoint is therefore NOT a broken-junction loose end —
1259 // bridging it would drag a chain end to a midpoint off the section
1260 // (a wrong patch) and the 3D gate would refuse a face the arrangement
1261 // handles exactly. Exclude an endpoint from bridge candidacy when its
1262 // chain intersects another CUT chain at a point interior to the chain
1263 // (beyond the junction radius of both its endpoints — an intersection
1264 // AT the far anchor is the ordinary shared-junction meeting and must
1265 // keep the endpoint eligible, which preserves the imported-geometry
1266 // broken-cut rescue: those chains stop SHORT of each other and have no
1267 // crossing at all).
1268 let cut_chain_has_interior_crossing = |i: usize| -> bool {
1269 let chain = &chains[i];
1270 chains.iter().enumerate().any(|(j, other)| {
1271 // A boundary-duplicate chain was dropped from the arrangement's
1272 // segment set; a crossing with it would not materialize a node.
1273 if j == i
1274 || dropped_chains.contains(&j)
1275 || !matches!(other.source, ChainSource::Cut { .. })
1276 {
1277 return false;
1278 }
1279 chain.segments.iter().any(|&(a0, a1)| {
1280 other.segments.iter().any(|&(b0, b1)| {
1281 let Some([t, _]) = crate::arrangement::segment_intersection(
1282 a0,
1283 a1,
1284 b0,
1285 b1,
1286 arrangement_tolerance,
1287 ) else {
1288 return false;
1289 };
1290 let crossing = a0.add(a1.sub(a0).scale(t));
1291 crossing.sub(chain.start).length() > junction_radius
1292 && crossing.sub(chain.end).length() > junction_radius
1293 })
1294 })
1295 })
1296 };
1297 let mut loose: Vec<(usize, bool, Vec2)> = Vec::new();
1298 for (i, c) in chains.iter().enumerate() {
1299 if !matches!(c.source, ChainSource::Cut { .. }) {
1300 continue;
1301 }
1302 for (is_end, this, other) in [(false, c.start, c.end), (true, c.end, c.start)] {
1303 if !anchored(i, this) && anchored(i, other) {
1304 if cut_chain_has_interior_crossing(i) {
1305 if debug {
1306 eprintln!(
1307 " cut chain {i} loose {} excluded from junction bridging — \
1308 the chain crosses another cut chain mid-run; arrangement \
1309 pruning owns the overshoot stub",
1310 if is_end { "end" } else { "start" },
1311 );
1312 }
1313 continue;
1314 }
1315 loose.push((i, is_end, this));
1316 }
1317 }
1318 }
1319 // A triple-point disagreement on imported geometry runs a few 1e-4 (the
1320 // edge<->vertex gap), well under a percent of the face diagonal; bound the
1321 // bridge span so two unrelated loose cuts across a face are never joined.
1322 let bridge_band = (diagonal * 0.02).max(junction_radius * 4.0);
1323 let mut pairs: Vec<(f64, usize, usize)> = Vec::new();
1324 for a in 0..loose.len() {
1325 for b in (a + 1)..loose.len() {
1326 if loose[a].0 == loose[b].0 {
1327 continue; // never bridge a chain to itself
1328 }
1329 let gap = loose[a].2.sub(loose[b].2).length();
1330 if gap <= bridge_band {
1331 pairs.push((gap, a, b));
1332 }
1333 }
1334 }
1335 pairs.sort_by(|x, y| x.0.total_cmp(&y.0));
1336 // 3D-DISTANCE CORROBORATION (case 08:10-genus, secondary defect): the
1337 // 2% UV band is metric-blind — on a big-carrier face (full-sphere
1338 // domain [0,1]² spanning hundreds of mm) it silently bridged loose
1339 // ends 8-14 mm apart in 3D, manufacturing UV-closed but 3D-OPEN loops
1340 // ("loop open between coedges" at assembly). A legitimate bridge heals
1341 // a triple-point disagreement (imported edge<->vertex gap, a small
1342 // fraction of the face scale); an impossible one spans a real missing
1343 // piece. Corroborate every candidate pair in 3D: evaluate the surface
1344 // at both (wrapped-back) loose ends and REFUSE — loudly, as a
1345 // missing-piece error — when their 3D gap exceeds 1% of the face's 3D
1346 // diameter (estimated over a coarse full-domain grid; on a small trim
1347 // of a big carrier this overstates the diameter, i.e. errs LENIENT —
1348 // the safe direction). Any probe evaluation failure falls back to the
1349 // old silent bridging (a diagnostic must not fail the operation).
1350 // Escape hatch: BREP_BRIDGE_3D_GATE=0 restores unconditional bridging.
1351 let bridge_3d_gate = std::env::var("BREP_BRIDGE_3D_GATE").as_deref() != Ok("0");
1352 let face_diameter3 = if bridge_3d_gate && !pairs.is_empty() {
1353 let mut samples: Vec<Vec3> = Vec::new();
1354 'grid: for i in 0..3 {
1355 for j in 0..3 {
1356 let u = u_domain[0] + (u_domain[1] - u_domain[0]) * i as f64 / 2.0;
1357 let v = v_domain[0] + (v_domain[1] - v_domain[0]) * j as f64 / 2.0;
1358 match face.surface.evaluate(u, v) {
1359 Ok(point) => samples.push(point),
1360 Err(_) => {
1361 samples.clear();
1362 break 'grid;
1363 }
1364 }
1365 }
1366 }
1367 let mut diameter = 0.0f64;
1368 for (index, a) in samples.iter().enumerate() {
1369 for b in samples.iter().skip(index + 1) {
1370 diameter = diameter.max(a.sub(*b).length());
1371 }
1372 }
1373 (diameter > 0.0).then_some(diameter)
1374 } else {
1375 None
1376 };
1377 let mut consumed = vec![false; loose.len()];
1378 let mut bridged = 0usize;
1379 for (gap, a, b) in pairs {
1380 if consumed[a] || consumed[b] {
1381 continue;
1382 }
1383 consumed[a] = true;
1384 consumed[b] = true;
1385 let (chain_a, _, point_a) = loose[a];
1386 let (chain_b, _, point_b) = loose[b];
1387 if let Some(diameter) = face_diameter3 {
1388 let wrapped_a = wrap_back(point_a);
1389 let wrapped_b = wrap_back(point_b);
1390 if let (Ok(position_a), Ok(position_b)) = (
1391 face.surface.evaluate(wrapped_a.x, wrapped_a.y),
1392 face.surface.evaluate(wrapped_b.x, wrapped_b.y),
1393 ) {
1394 let gap3 = position_a.sub(position_b).length();
1395 let limit = 0.01 * diameter;
1396 if gap3 > limit {
1397 return Err(format!(
1398 "fragment_face {}: cut junction bridge refused — 3D gap \
1399 {gap3:.4} exceeds {limit:.4} (1% of face diameter \
1400 {diameter:.4}) across uv gap {gap:.6}; a section piece \
1401 is missing between chains {chain_a} and {chain_b}",
1402 face.id
1403 ));
1404 }
1405 }
1406 }
1407 let midpoint = point_a.add(point_b).scale(0.5);
1408 for (chain, old) in [(chain_a, point_a), (chain_b, point_b)] {
1409 if chains[chain].start.sub(old).length() <= 1e-15 {
1410 chains[chain].start = midpoint;
1411 }
1412 if chains[chain].end.sub(old).length() <= 1e-15 {
1413 chains[chain].end = midpoint;
1414 }
1415 for seg in &mut chains[chain].segments {
1416 if seg.0.sub(old).length() <= 1e-15 {
1417 seg.0 = midpoint;
1418 }
1419 if seg.1.sub(old).length() <= 1e-15 {
1420 seg.1 = midpoint;
1421 }
1422 }
1423 for segment in segments.iter_mut() {
1424 if segment.tag["chain"].as_u64() != Some(chain as u64) {
1425 continue;
1426 }
1427 if segment.a.sub(old).length() <= 1e-15 {
1428 segment.a = midpoint;
1429 }
1430 if segment.b.sub(old).length() <= 1e-15 {
1431 segment.b = midpoint;
1432 }
1433 }
1434 }
1435 bridged += 1;
1436 }
1437 if debug && bridged > 0 {
1438 eprintln!(" bridged {bridged} broken cut junction(s)");
1439 }
1440 }
1441 // GRAZE-BAND COMMON-BLOCK — near-tangent hook absorption (CAPSTONE-DESIGN.md
1442 // §3, member t548). fragment_face runs on the ALREADY edge-split solid, so
1443 // every imprint-minted section×boundary crossing is already a chain endpoint
1444 // by construction. A near-tangent SSI section ending at a shared junction
1445 // vertex can HOOK in its last span: its sampled pcurve overshoots and crosses
1446 // the boundary edge a graze before reaching the shared endpoint (the crossing
1447 // add_edge_split already REFUSED as sub-weld, so the imprint minted only the
1448 // shared vertex). arrange_segments, seeing the linearised hook, re-invents that
1449 // crossing as a distinct INTERIOR point Q, splits the boundary there, and
1450 // spawns a sub-band micro-stub that strands one-use at assembly (t548's
1451 // A→B→C needle). Absorb it deterministically: when a Cut chain and a Boundary
1452 // chain SHARE an endpoint node P, and the cut's polyline crosses that boundary
1453 // at an interior point Q within a MEASURED band of P (3D via surface.evaluate;
1454 // ceiling = the residual-merge sep_cap family, never grown, never the poisoned
1455 // projector), CLIP the cut's hooked tail — drop the samples between the
1456 // crossing and P and run straight into P. The cut's SOURCE curve is untouched,
1457 // so the complete-run path in build_loop still emits the full section edge to
1458 // P; only the spurious arrangement self-crossing is removed. This is the
1459 // arrangement sibling of BREP_SAG_CROSSING_GUARD ("sag refinement may REMOVE a
1460 // crossing but never ADD one"): the arrangement may not invent a boundary
1461 // crossing the imprint did not mint. The structural gate (shared endpoint +
1462 // strictly-interior sub-band crossing) keeps it off legitimate transversals —
1463 // an interior crossing BEYOND the band bails the chain untouched. Escape hatch
1464 // BREP_GRAZE_HOOK_CLIP=0.
1465 if std::env::var("BREP_GRAZE_HOOK_CLIP").as_deref() != Ok("0") {
1466 let raw_extent = if solid.vertices.is_empty() {
1467 0.0
1468 } else {
1469 let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1470 let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1471 for v in &solid.vertices {
1472 lo.x = lo.x.min(v.point.x);
1473 lo.y = lo.y.min(v.point.y);
1474 lo.z = lo.z.min(v.point.z);
1475 hi.x = hi.x.max(v.point.x);
1476 hi.y = hi.y.max(v.point.y);
1477 hi.z = hi.z.max(v.point.z);
1478 }
1479 hi.sub(lo).length()
1480 };
1481 // Mirror imprint::residual_merge_bands' sep_cap (the measured junction-radius
1482 // family, 1.5e-3·extent, floored by the achieved weld band) — the largest
1483 // near-tangent hook a shared-junction section can plausibly span.
1484 let band = 2.0 * 1e-5 * raw_extent.max(1.0);
1485 let sep_cap = (1.5e-3 * raw_extent).max(band);
1486 let graze_debug = debug || std::env::var("BREP_DEBUG_GRAZE").as_deref() == Ok("1");
1487
1488 let mut plans: Vec<(usize, Vec<(Vec2, Vec2)>, f64)> = Vec::new();
1489 for ci in 0..chains.len() {
1490 if !matches!(chains[ci].source, ChainSource::Cut { .. }) {
1491 continue;
1492 }
1493 // A chain already dropped as a boundary duplicate has had its flat
1494 // `segments` removed; never re-materialise it below.
1495 if dropped_chains.contains(&ci) {
1496 continue;
1497 }
1498 if chains[ci].segments.is_empty() {
1499 continue;
1500 }
1501 let mut lo_cut: Option<usize> = None; // clip a start hook after this seg
1502 let mut hi_cut: Option<usize> = None; // clip an end hook from this seg on
1503 let mut measured = 0.0f64;
1504 let mut bail = false;
1505 for &at_end in &[false, true] {
1506 let p = if at_end {
1507 chains[ci].end
1508 } else {
1509 chains[ci].start
1510 };
1511 let Ok(p3) = face.surface.evaluate(p.x, p.y) else {
1512 continue;
1513 };
1514 for bi in 0..chains.len() {
1515 if bi == ci || !matches!(chains[bi].source, ChainSource::Boundary { .. }) {
1516 continue;
1517 }
1518 let shares = chains[bi].start.sub(p).length() <= node_tolerance
1519 || chains[bi].end.sub(p).length() <= node_tolerance;
1520 if !shares {
1521 continue;
1522 }
1523 for si in 0..chains[ci].segments.len() {
1524 let (sa, sb) = chains[ci].segments[si];
1525 for &(ba, bb) in &chains[bi].segments {
1526 let Some([t1, t2]) = crate::arrangement::segment_intersection(
1527 sa,
1528 sb,
1529 ba,
1530 bb,
1531 arrangement_tolerance,
1532 ) else {
1533 continue;
1534 };
1535 // Strictly interior on BOTH segments: not the shared-node
1536 // touch (t≈0/1) and not a boundary-endpoint (imprint-minted)
1537 // crossing (t2≈0/1) — an arrangement-invented self-crossing.
1538 if !(t1 > 1e-3 && t1 < 1.0 - 1e-3 && t2 > 1e-3 && t2 < 1.0 - 1e-3) {
1539 continue;
1540 }
1541 let q = Vec2 {
1542 x: sa.x + (sb.x - sa.x) * t1,
1543 y: sa.y + (sb.y - sa.y) * t1,
1544 };
1545 let Ok(q3) = face.surface.evaluate(q.x, q.y) else {
1546 continue;
1547 };
1548 let dist = q3.sub(p3).length();
1549 if dist > sep_cap {
1550 // A genuine transversal far from the shared vertex —
1551 // preserve current behaviour, touch nothing.
1552 bail = true;
1553 break;
1554 }
1555 measured = measured.max(dist);
1556 if at_end {
1557 hi_cut = Some(hi_cut.map_or(si, |m| m.min(si)));
1558 } else {
1559 lo_cut = Some(lo_cut.map_or(si, |m| m.max(si)));
1560 }
1561 }
1562 if bail {
1563 break;
1564 }
1565 }
1566 if bail {
1567 break;
1568 }
1569 }
1570 if bail {
1571 break;
1572 }
1573 }
1574 if bail || (lo_cut.is_none() && hi_cut.is_none()) {
1575 continue;
1576 }
1577 let segs = &chains[ci].segments;
1578 let keep_lo = lo_cut.map(|m| m + 1).unwrap_or(0);
1579 let keep_hi = hi_cut.unwrap_or(segs.len());
1580 if keep_lo >= keep_hi {
1581 continue;
1582 }
1583 let mut points: Vec<Vec2> = Vec::with_capacity(keep_hi - keep_lo + 3);
1584 if lo_cut.is_some() {
1585 points.push(chains[ci].start);
1586 }
1587 points.push(segs[keep_lo].0);
1588 for si in keep_lo..keep_hi {
1589 points.push(segs[si].1);
1590 }
1591 if hi_cut.is_some() {
1592 points.push(chains[ci].end);
1593 }
1594 let new_segments: Vec<(Vec2, Vec2)> = points
1595 .windows(2)
1596 .filter(|w| w[0].sub(w[1]).length() > 0.0)
1597 .map(|w| (w[0], w[1]))
1598 .collect();
1599 if new_segments.is_empty() {
1600 continue;
1601 }
1602 if graze_debug {
1603 eprintln!(
1604 "graze-hook-clip: face {} cut chain {} measured_band={:.3e} sep_cap={:.3e} \
1605 segs {} -> {} (lo_cut={:?} hi_cut={:?})",
1606 face.id,
1607 ci,
1608 measured,
1609 sep_cap,
1610 chains[ci].segments.len(),
1611 new_segments.len(),
1612 lo_cut,
1613 hi_cut
1614 );
1615 }
1616 plans.push((ci, new_segments, measured));
1617 }
1618 for (ci, new_segments, _) in plans {
1619 chains[ci].count = new_segments.len();
1620 chains[ci].segments = new_segments.clone();
1621 segments.retain(|s| s.tag["chain"].as_u64() != Some(ci as u64));
1622 for (a, b) in new_segments {
1623 segments.push(Segment2 {
1624 a,
1625 b,
1626 tag: json!({ "chain": ci }),
1627 });
1628 }
1629 }
1630 }
1631 let regions = arrange_segments(&segments, arrangement_tolerance)?;
1632 let mut fragments = Vec::new();
1633 for region in regions {
1634 let candidates: Vec<Vec2> = interior_points(®ion.outer, ®ion.holes, region.area, 3)
1635 .into_iter()
1636 .map(wrap_back)
1637 .collect();
1638 let Some(&test_uv) = candidates.first() else {
1639 if debug {
1640 eprintln!(" region area={:.6e}: no interior point", region.area);
1641 }
1642 continue;
1643 };
1644 if parameter_point_in_face(face, test_uv, 1e-9)? != PolygonClass::Inside {
1645 if debug {
1646 eprintln!(
1647 " region area={:.6e} test=({:.9},{:.9}): outside trimmed face",
1648 region.area, test_uv.x, test_uv.y
1649 );
1650 }
1651 continue;
1652 }
1653 if debug {
1654 eprintln!(
1655 " region area={:.6e} test=({:.9},{:.9}): KEPT",
1656 region.area, test_uv.x, test_uv.y
1657 );
1658 }
1659 let Some(outer_loop) = build_loop(
1660 ®ion.outer,
1661 !face.same_sense,
1662 &chains,
1663 arrangement_tolerance,
1664 &face.surface,
1665 )?
1666 else {
1667 // Spurious zero-area antenna region (e.g. a seam-grazing fold on a
1668 // periodic band); it bounds no material, so drop it.
1669 if debug {
1670 eprintln!(" region area={:.6e}: dropped (degenerate antenna)", region.area);
1671 }
1672 continue;
1673 };
1674 let mut loops = vec![outer_loop];
1675 for hole in ®ion.holes {
1676 if let Some(hole_loop) = build_loop(
1677 hole,
1678 !face.same_sense,
1679 &chains,
1680 arrangement_tolerance,
1681 &face.surface,
1682 )? {
1683 loops.push(hole_loop);
1684 }
1685 }
1686 for loop_record in &mut loops {
1687 for coedge in &mut loop_record.coedges {
1688 if let FragmentEdgeSource::Boundary {
1689 operand: source_operand,
1690 ..
1691 } = &mut coedge.source
1692 {
1693 *source_operand = operand;
1694 }
1695 }
1696 }
1697 let mut extra_test_points = Vec::new();
1698 for &candidate in candidates.iter().skip(1) {
1699 if parameter_point_in_face(face, candidate, 1e-9)? == PolygonClass::Inside {
1700 extra_test_points.push(face.surface.evaluate(candidate.x, candidate.y)?);
1701 }
1702 }
1703 fragments.push(FaceFragmentRecord {
1704 operand,
1705 source_face_id: face.id,
1706 surface: face.surface.clone(),
1707 same_sense: face.same_sense,
1708 loops,
1709 test_point: face.surface.evaluate(test_uv.x, test_uv.y)?,
1710 test_uv,
1711 extra_test_points,
1712 });
1713 }
1714
1715 // Rescue an UNCUT face the flat 2D arrangement failed to reconstruct.
1716 // A seam-closed multi-loop face (a band on a surface of revolution, whose
1717 // param-space loops only close across the u/v seam, or a holed face whose
1718 // domain centre lands in the hole so the fast path above declined it)
1719 // collapses to zero-area lines in the [0,1]^2 arrangement and yields NO
1720 // region -> the face is dropped entirely, stranding its neighbours' shared
1721 // edges as one-use and knocking the assembly's genus off an integer. Since
1722 // the face is uncut, its loops already describe the exact trimmed boundary,
1723 // so pass the unchanged face through — we only need a valid interior test
1724 // point, found by gridding the ACTUAL knot domain. Gated on
1725 // `fragments.is_empty()`, this never touches a face the arrangement handled.
1726 if fragments.is_empty() && piece_ids.is_empty() {
1727 const GRID: usize = 12;
1728 let mut test_uv: Option<Vec2> = None;
1729 let mut extra_test_points: Vec<Vec3> = Vec::new();
1730 'search: for iu in 1..GRID {
1731 for iv in 1..GRID {
1732 let candidate = Vec2 {
1733 x: u_domain[0] + (u_domain[1] - u_domain[0]) * iu as f64 / GRID as f64,
1734 y: v_domain[0] + (v_domain[1] - v_domain[0]) * iv as f64 / GRID as f64,
1735 };
1736 if parameter_point_in_face(face, candidate, 1e-9)? == PolygonClass::Inside {
1737 if test_uv.is_none() {
1738 test_uv = Some(candidate);
1739 } else {
1740 extra_test_points.push(face.surface.evaluate(candidate.x, candidate.y)?);
1741 if extra_test_points.len() >= 2 {
1742 break 'search;
1743 }
1744 }
1745 }
1746 }
1747 }
1748 // SEAM-STRADDLING BAND fallback (a SINGLY-periodic surface of
1749 // revolution whose trim loop closes only ACROSS the u/v seam — e.g. a
1750 // rounded imported cap grazed near-tangent, so it stays uncut). The
1751 // even-odd `parameter_point_in_face` reads Outside EVERYWHERE on such a
1752 // loop (the seam hop makes the [0,1]^2 polygon non-simple), so the knot
1753 // grid above finds nothing and the face would still drop. `seam_band_
1754 // point_in_face` only covers the DOUBLY-periodic (torus) case, so the
1755 // singly-periodic band has no seam-aware classifier. Unwrap the loop
1756 // onto the surface's covering plane (`loop_seam_offsets`, the same fold
1757 // the mass integrator and tessellator use), where it IS a simple
1758 // polygon, grid that unwrapped bbox with the plain even-odd test, and
1759 // map an interior hit back into the domain. Runs ONLY when the grid
1760 // already failed AND the offsets show the loop truly straddled a seam
1761 // (a full-wrap rim yields all-zero offsets and is left untouched), so it
1762 // never changes a face any earlier path handled.
1763 if test_uv.is_none() {
1764 let (closed_u, closed_v) = face.surface.closed_directions()?;
1765 if closed_u || closed_v {
1766 let u_period = u_domain[1] - u_domain[0];
1767 let v_period = v_domain[1] - v_domain[0];
1768 let mut loop_polygons: Vec<Vec<Vec2>> = Vec::new();
1769 let mut straddled = false;
1770 let (mut umin, mut umax, mut vmin, mut vmax) = (
1771 f64::INFINITY,
1772 f64::NEG_INFINITY,
1773 f64::INFINITY,
1774 f64::NEG_INFINITY,
1775 );
1776 for loop_record in &face.loops {
1777 let offsets = crate::topology::loop_seam_offsets(
1778 &loop_record.coedges,
1779 closed_u,
1780 closed_v,
1781 u_period,
1782 v_period,
1783 )?;
1784 if offsets.iter().any(|o| o[0] != 0.0 || o[1] != 0.0) {
1785 straddled = true;
1786 }
1787 let mut polygon = Vec::new();
1788 for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
1789 let [d0, d1] = coedge.pcurve.domain()?;
1790 let samples = 32usize;
1791 for k in 0..samples {
1792 let t = d0 + (d1 - d0) * k as f64 / samples as f64;
1793 let p = coedge.pcurve.evaluate(t)?;
1794 let uv = Vec2 {
1795 x: p.x + offsets[coedge_index][0],
1796 y: p.y + offsets[coedge_index][1],
1797 };
1798 umin = umin.min(uv.x);
1799 umax = umax.max(uv.x);
1800 vmin = vmin.min(uv.y);
1801 vmax = vmax.max(uv.y);
1802 polygon.push(uv);
1803 }
1804 }
1805 loop_polygons.push(polygon);
1806 }
1807 if straddled && umax > umin && vmax > vmin {
1808 let wrap = |value: f64, lo: f64, span: f64| {
1809 if span > 0.0 {
1810 lo + (value - lo).rem_euclid(span)
1811 } else {
1812 value
1813 }
1814 };
1815 'seam: for iu in 1..GRID {
1816 for iv in 1..GRID {
1817 let cu = umin + (umax - umin) * iu as f64 / GRID as f64;
1818 let cv = vmin + (vmax - vmin) * iv as f64 / GRID as f64;
1819 let candidate = Vec2 { x: cu, y: cv };
1820 // Even-odd fill across ALL unwrapped loops: interior
1821 // of the trimmed region is where the winding is odd
1822 // (outer loop minus any hole loops), independent of
1823 // which loop is the outer one.
1824 let winding = loop_polygons
1825 .iter()
1826 .filter(|polygon| point_in_polygon(candidate, polygon))
1827 .count();
1828 if winding % 2 == 0 {
1829 continue;
1830 }
1831 let mapped = Vec2 {
1832 x: if closed_u {
1833 wrap(cu, u_domain[0], u_period)
1834 } else {
1835 cu
1836 },
1837 y: if closed_v {
1838 wrap(cv, v_domain[0], v_period)
1839 } else {
1840 cv
1841 },
1842 };
1843 if test_uv.is_none() {
1844 test_uv = Some(mapped);
1845 } else {
1846 extra_test_points
1847 .push(face.surface.evaluate(mapped.x, mapped.y)?);
1848 if extra_test_points.len() >= 2 {
1849 break 'seam;
1850 }
1851 }
1852 }
1853 }
1854 }
1855 }
1856 }
1857 if let Some(uv) = test_uv {
1858 fragments.push(FaceFragmentRecord {
1859 operand,
1860 source_face_id: face.id,
1861 surface: face.surface.clone(),
1862 same_sense: face.same_sense,
1863 loops: face
1864 .loops
1865 .iter()
1866 .map(|loop_record| FragmentLoop {
1867 coedges: loop_record
1868 .coedges
1869 .iter()
1870 .map(|coedge| FragmentCoedge {
1871 source: FragmentEdgeSource::Boundary {
1872 operand,
1873 edge_id: coedge.edge_id,
1874 },
1875 forward: coedge.forward,
1876 pcurve: coedge.pcurve.clone(),
1877 })
1878 .collect(),
1879 })
1880 .collect(),
1881 test_point: face.surface.evaluate(uv.x, uv.y)?,
1882 test_uv: uv,
1883 extra_test_points,
1884 });
1885 }
1886 }
1887 Ok(fragments)
1888}
1889
1890pub fn fragment_solid(
1891 solid: &BrepSolid,
1892 operand: u8,
1893 imprint: &ImprintResultRecord,
1894) -> Result<Vec<FaceFragmentRecord>, String> {
1895 let index = FragmentIndex::build(imprint);
1896 let mut fragments = Vec::new();
1897 for face in solid.shells.iter().flat_map(|shell| shell.faces.iter()) {
1898 fragments.extend(fragment_face_indexed(solid, operand, face, &index)?);
1899 }
1900 Ok(fragments)
1901}
1902