brep_kernel/edit/direct_edit/face_move_carrier.rs
1use super::*;
2use crate::RevolutionFrame;
3
4// ---------------------------------------------------------------------------
5// Move Face by CARRIER RE-INTERSECTION — the operator's construction.
6// ---------------------------------------------------------------------------
7//
8// > "I need the infinite faces of the move face selection to go and intersect
9// > themselves with the rest of the solid as the thing gets moved."
10//
11// `face_move.rs` ENUMERATES CASES. It classifies each boundary edge by the
12// carrier pair it joins and picks one of four actions, and every configuration
13// nobody enumerated becomes a refusal — which is the root cause the
14// 2026-09-21 capability matrix convicts in three places (an in-plane slide
15// that refuses because a NEIGHBOUR is curved; a bore wall pushed along its own
16// invariant axis; acceptance that is not monotone in the motion).
17//
18// This road has no case table over configurations. It has exactly three steps,
19// and each one asks the same question of every face regardless of what kind of
20// surface it carries:
21//
22// 1. **Carriers.** Every face's carrier is taken UNBOUNDED — stretched to
23// cover the whole body plus the motion — and the selected faces' carriers
24// are moved. Nothing is trimmed at this stage; the body supplies the
25// extent later, which is the whole point.
26// 2. **Corners.** Every vertex the moved group touches goes where the rigid
27// motion would put it, corrected by the LEAST amount that lands it on
28// every carrier it must lie on. That one sentence replaces the old road's
29// three-plane solve, its two rim affines, its "ride the fixed edge" lane
30// and its invariant-carrier lane. A corner whose carriers do not meet
31// near the rigid guess refuses, by name.
32// 3. **Edges and trims.** Every edge whose geometry the motion disturbed is
33// rebuilt as the section of its own two carriers between its own two
34// re-solved corners, and every face the rebuild touched regrows its
35// carrier around its new boundary and refits its pcurves.
36//
37// ## What is dispatched on, and what is not
38//
39// Step 3 does dispatch on the SURFACE PAIR — a plane pair sections in a line, a
40// recognized analytic pair sections in closed form
41// (`intersect_analytic_pair`), anything else is marched. That is a geometry
42// library's business and it is exhaustive by fallback: the marcher has no
43// "unsupported pair". What this road does NOT dispatch on is the
44// CONFIGURATION — which side of the edge moved, whether the neighbour is
45// curved, whether the push is parallel to an axis, how many fixed faces meet
46// at a corner. Those are the distinctions the old road's refusals are made of.
47//
48// ## Honest scope of this slice
49//
50// * **Topology is preserved.** A motion that needs the topology to CHANGE — a
51// bore breaking out through a new face, a migrating exit hole — is not
52// attempted here; it refuses. Slice 1 measures the geometry road.
53// * **Free-form carriers are not stretched.** A fitted NURBS patch keeps its
54// own domain, so a section that leaves it refuses instead of extrapolating.
55// * The entry is `move_faces` under `BREP_FACE_MOVE_ROAD=carrier`. Nothing is
56// rewired: with the variable unset, not one byte of the old road's behaviour
57// changes.
58
59/// Which road `move_faces` takes. The old one unless `BREP_FACE_MOVE_ROAD` is
60/// set to `carrier` — so an unset environment is the tree's behaviour exactly.
61pub(super) fn carrier_road_selected() -> bool {
62 std::env::var("BREP_FACE_MOVE_ROAD").as_deref() == Ok("carrier")
63}
64
65const OP: &str = "move_faces(carrier)";
66
67/// How a vertex is allowed to sit off the carriers it was solved against,
68/// and how far a rebuilt edge may sit off its own section, as a multiple of
69/// the model tolerance. Deliberately the same slack the old road grants its
70/// own re-solved corners (`10.0 * tolerance`).
71const RESIDUAL_SLACK: f64 = 10.0;
72
73/// The section two carriers share, in the only two shapes a caller here needs.
74enum Section {
75 /// A plane pair. `intersect_analytic_pair` deliberately declines this one
76 /// (its doc says so: a synthesized over-long segment interacts badly with
77 /// the shared-parameter pcurve contract), and marching it would fit a
78 /// straight line to a polyline and lose the exactness every planar closed
79 /// form in the capability matrix is measured to. So a plane pair is built
80 /// as the straight segment between the corners the solve already placed —
81 /// which IS the section restricted to them, because both corners were
82 /// solved onto both planes.
83 Straight,
84 /// Anything else: the exact closed form where the pair has one, the marched
85 /// trace where it does not. `reintersect_carriers` owns that choice.
86 Curve(NurbsCurve),
87}
88
89/// Move a face group by re-intersecting its carriers with the rest of the
90/// solid. See the module header for the construction; `move_faces` routes here
91/// under `BREP_FACE_MOVE_ROAD=carrier`.
92pub(super) fn move_faces_by_carrier(
93 solid: &BrepSolid,
94 moved: &HashSet<u64>,
95 translation: Vec3,
96) -> Result<BrepSolid, String> {
97 let scale = solid_model_scale(solid);
98 let tolerance = (scale * 1e-7).max(1e-9);
99 let plane_tolerance = (scale * 1e-6).max(1e-7);
100 let motion = translate_affine(translation)?;
101
102 let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
103 for (shell_index, shell) in solid.shells.iter().enumerate() {
104 for (face_index, face) in shell.faces.iter().enumerate() {
105 face_lookup.entry(face.id).or_insert((shell_index, face_index));
106 }
107 }
108
109 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
110 for shell in &solid.shells {
111 for face in &shell.faces {
112 for loop_record in &face.loops {
113 for coedge in &loop_record.coedges {
114 faces_of_edge.entry(coedge.edge_id).or_default().push(face.id);
115 }
116 }
117 }
118 }
119 for edge in &solid.edges {
120 let uses = faces_of_edge.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
121 let expected = if edge.degenerate { 1 } else { 2 };
122 if uses.len() != expected {
123 return Err(format!(
124 "{OP}: edge {} is used {} times (non-manifold input)",
125 edge.id,
126 uses.len()
127 ));
128 }
129 }
130
131 // --- 1. The carriers, unbounded, and already moved ---------------------
132 let (centre, radius) = body_ball(solid)?;
133 // The reach a carrier has to cover for the body to be able to supply the
134 // extent: the whole body, wherever the motion carries it, with room to
135 // spare. This is what "infinite" means in a kernel that stores patches.
136 let reach = (radius + translation.length()) * 4.0 + scale;
137 let mut carriers: HashMap<u64, NurbsSurface> = HashMap::default();
138 // Which SELECTED faces the motion actually moves the CARRIER of.
139 //
140 // A motion can be a real motion of the face and the identity on its
141 // carrier: a plane slid inside itself, a cylinder pushed along its own
142 // axis, a face spun about its own normal. The contract already says what
143 // those answer — "the identity, geometry and topology unchanged" — and the
144 // old road refuses two of them, which is category 4 of the capability
145 // matrix. This is the reading that makes them the identity here, and it is
146 // a MEASUREMENT applied to every selected face alike, not a list of the
147 // configurations it holds in: sample the face's own patch, carry the
148 // samples by the motion, and ask whether they are still on the carrier
149 // they came from. Nothing in it mentions what kind of surface that is.
150 //
151 // The threshold is much tighter than the model tolerance on purpose. At
152 // model tolerance a 1e-9 push of a box top reads as invariant and the road
153 // answers with the input body, which is a DIFFERENT answer from the one the
154 // old road gives (it moves the face by 1e-9). Whether a sub-tolerance drag
155 // ought to be the identity is a contract question and not this slice's to
156 // settle, so the reading is set where it cannot decide it: five orders
157 // below the smallest motion any cell asks for, and four above the
158 // floating-point noise a genuinely invariant carrier's own samples carry.
159 let invariance_tolerance = (scale * 1e-11).max(1e-13);
160 let mut moves_carrier: HashSet<u64> = HashSet::default();
161 for shell in &solid.shells {
162 for face in &shell.faces {
163 let stretched = unbounded_carrier(&face.surface, centre, reach, plane_tolerance)?;
164 let carrier = if moved.contains(&face.id)
165 && !carrier_invariant_under_motion(
166 &face.surface,
167 &stretched,
168 motion,
169 invariance_tolerance,
170 )?
171 {
172 moves_carrier.insert(face.id);
173 transform_surface(&stretched, motion)?
174 } else {
175 stretched
176 };
177 carriers.insert(face.id, carrier);
178 }
179 }
180
181 // --- 2. The corners ----------------------------------------------------
182 let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
183 for edge in &solid.edges {
184 if let Some(uses) = faces_of_edge.get(&edge.id) {
185 for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
186 vertex_faces.entry(vertex_id).or_default().extend(uses.iter().copied());
187 }
188 }
189 }
190 let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
191 for vertex in &solid.vertices {
192 let Some(adjacent) = vertex_faces.get(&vertex.id) else {
193 continue;
194 };
195 if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
196 continue;
197 }
198 // THE RULE, and there is only one: the corner goes where the rigid
199 // motion would put it, corrected by the least amount that lands it on
200 // every carrier it must lie on. When every adjacent face moved, the
201 // rigid guess is already on all of them and the correction is zero.
202 // When the motion leaves a carrier invariant — a plane slid in itself,
203 // a cylinder pushed along its own axis — the guess is off it and the
204 // correction is exactly the slide back, which is why an identity
205 // motion comes out as the identity here with nothing written for it.
206 let mut at: Vec<&NurbsSurface> = Vec::with_capacity(adjacent.len());
207 let mut ids: Vec<u64> = adjacent.iter().copied().collect();
208 ids.sort_unstable();
209 for face_id in &ids {
210 at.push(carriers.get(face_id).ok_or_else(|| {
211 format!("{OP}: no carrier for face {face_id} at vertex {}", vertex.id)
212 })?);
213 }
214 // The corner is only forced to move by a carrier the motion CHANGES.
215 // When every carrier meeting here is one the motion maps onto itself,
216 // the constraint set is the one the corner already satisfies and the
217 // rigid guess would be a displacement nothing asked for.
218 let seed = if ids.iter().any(|face_id| moves_carrier.contains(face_id)) {
219 vertex.point.add(translation)
220 } else {
221 vertex.point
222 };
223 if std::env::var("BREP_FACE_MOVE_CARRIER_DEBUG").is_ok() {
224 eprintln!("CORNER v{} at faces {:?} seed {:?}", vertex.id, ids, seed);
225 for (face_id, surface) in ids.iter().zip(at.iter()) {
226 let kind = match surface.analytic() {
227 Some(AnalyticSurface::Plane { .. }) => "plane",
228 Some(AnalyticSurface::RuledRevolution { .. }) => "ruled",
229 Some(AnalyticSurface::Sphere { .. }) => "sphere",
230 Some(AnalyticSurface::Torus { .. }) => "torus",
231 Some(AnalyticSurface::Revolution { .. }) => "revolution",
232 None => "free-form",
233 };
234 match crate::project_point_to_surface(surface, seed) {
235 Ok(projection) => eprintln!(
236 " face {face_id} [{kind}] foot {:?} dist {:.6e} uv ({:.6},{:.6}) normal {:?}",
237 projection.point,
238 projection.point.sub(seed).length(),
239 projection.u,
240 projection.v,
241 surface.normal(projection.u, projection.v),
242 ),
243 Err(error) => eprintln!(" face {face_id} [{kind}] projection FAILED: {error}"),
244 }
245 }
246 }
247 let corner = solve_corner(&at, seed, tolerance).map_err(|why| {
248 // Named the way the user picked the faces, not by internal id. The
249 // capability matrix graded every refusal this operation writes and
250 // split them exactly on this: the messages the FEATURE writes name
251 // faces the user can see, and the messages the PRIMITIVE writes
252 // name `edge 18`, `vertex 9`, `vertex 21` — ids a user cannot
253 // select and which change between runs of the same document.
254 let named: Vec<String> = ids
255 .iter()
256 .map(|face_id| face_label(solid, &face_lookup, *face_id))
257 .collect();
258 let split = if ids.len() > 3 {
259 " — more than three carriers met there, and a corner like that cannot stay \
260 ONE corner once any of them moves: the right answer splits it, which is a \
261 topology change this operation does not make"
262 } else {
263 ""
264 };
265 format!(
266 "{OP}: the {} carriers meeting at the corner of {} no longer share a point \
267 after the motion ({why}){split}",
268 ids.len(),
269 named.join(", ")
270 )
271 })?;
272 new_vertex.insert(vertex.id, corner);
273 }
274
275 // --- 3. The edges ------------------------------------------------------
276 // An edge is DIRTY when the motion disturbed its geometry: either a face it
277 // bounds moved, or a corner it ends on did. The second half is what lets a
278 // FIXED edge between two fixed carriers be re-trimmed (and, when it must,
279 // re-derived from those same two carriers past its old span) instead of
280 // being the thing a push refuses for not re-crossing within its span.
281 let mut result = solid.clone();
282 let mut dirty: HashSet<u64> = HashSet::default();
283 let mut rebuilt: HashMap<u64, (NurbsCurve, f64, f64)> = HashMap::default();
284 let mut order: Vec<&EdgeRecord> = Vec::new();
285 for edge in &solid.edges {
286 let uses = faces_of_edge.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
287 let moved_uses = uses.iter().filter(|face_id| moves_carrier.contains(*face_id)).count();
288 let corner_moved = |vertex_id: u64| {
289 new_vertex
290 .get(&vertex_id)
291 .is_some_and(|point| point.sub(point_of(solid, vertex_id)).length() > tolerance)
292 };
293 let ends_moved =
294 corner_moved(edge.start_vertex_id) || corner_moved(edge.end_vertex_id);
295 // Nothing changed here, so nothing is rebuilt: an edge whose carriers
296 // the motion leaves alone and whose corners did not move IS the edge it
297 // was, exactly, and re-deriving it could only add fit noise to an
298 // answer the contract says is the input.
299 if moved_uses == 0 && !ends_moved {
300 continue;
301 }
302 dirty.insert(edge.id);
303 order.push(edge);
304 }
305
306 // 3a. THE CLOSED RIMS FIRST, because they carry their own corner.
307 //
308 // A rim whose two ends are the same vertex — a bore's mouth — is a whole
309 // conic, and its carriers meet in a CURVE, not a point: no third face meets
310 // at that vertex, so nothing determines where along the rim it sits. The
311 // section itself does. `intersect_plane_quadric` anchors its answer at the
312 // carrier frame's own `x_axis`, which is the carrier's seam, and the vertex
313 // is where the face's seam meets the rim — so taking the vertex FROM the
314 // rebuilt section is not a convention invented here, it is the only reading
315 // on which the vertex and the seam edge that ends on it can agree.
316 //
317 // It is also what makes the two bore cells come out right for the same
318 // reason rather than by opposite rules: a bore pushed ACROSS its axis
319 // carries its seam with it (the frame moved, so the anchor moved), and a
320 // bore pushed ALONG its axis, or a cap slid in its own plane, leaves the
321 // seam exactly where it was (the frame's azimuth did not move).
322 for edge in &order {
323 if edge.start_vertex_id != edge.end_vertex_id || edge.degenerate {
324 continue;
325 }
326 let uses = faces_of_edge.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
327 let (Some(&first), Some(&second)) = (uses.first(), uses.get(1)) else {
328 continue;
329 };
330 if first == second {
331 continue;
332 }
333 let section = carrier_section(
334 edge,
335 carrier_of(&carriers, first)?,
336 carrier_of(&carriers, second)?,
337 tolerance,
338 )?;
339 let Section::Curve(curve) = section else {
340 return Err(format!(
341 "{OP}: the closed rim {} sections in a straight line, which cannot close — \
342 refusing",
343 edge.id
344 ));
345 };
346 let matched = match_marched_rim_direction(curve, edge)?;
347 let [d0, d1] = matched.domain()?;
348 let anchor = matched.evaluate(d0)?;
349 new_vertex.insert(edge.start_vertex_id, anchor);
350 rebuilt.insert(edge.id, (matched, d0, d1));
351 }
352
353 // 3b. Everything else.
354 for edge in &order {
355 if rebuilt.contains_key(&edge.id) {
356 continue;
357 }
358 let uses = faces_of_edge.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
359 let moved_uses = uses.iter().filter(|face_id| moves_carrier.contains(*face_id)).count();
360 let old_start = edge.curve.evaluate(edge.t0)?;
361 let old_end = edge.curve.evaluate(edge.t1)?;
362 let start_new = new_vertex
363 .get(&edge.start_vertex_id)
364 .copied()
365 .unwrap_or_else(|| point_of(solid, edge.start_vertex_id));
366 let end_new = new_vertex
367 .get(&edge.end_vertex_id)
368 .copied()
369 .unwrap_or_else(|| point_of(solid, edge.end_vertex_id));
370 let first = *uses.first().ok_or_else(|| format!("{OP}: edge {} has no faces", edge.id))?;
371 let second = uses.get(1).copied();
372
373 // A rigid shortcut that carries a PROOF, not a case: when every carrier
374 // this edge lies on moved by the same rigid motion AND both its corners
375 // came out at their own rigid images, the section of the moved carriers
376 // between them IS the rigid image of the old edge — so mapping it keeps
377 // the parametrisation, the weights and the knot vector exact. Where
378 // either half of that fails, the general path answers instead, and the
379 // three moved faces of a corner lift are exactly where it fails: two
380 // moved planes still meet a FIXED one, so their shared edge's corner
381 // slides ALONG the edge and is not the rigid image of anything.
382 let rigid_ends = start_new.sub(old_start.add(translation)).length() <= tolerance
383 && end_new.sub(old_end.add(translation)).length() <= tolerance;
384 if moved_uses == uses.len() && rigid_ends {
385 rebuilt.insert(
386 edge.id,
387 (transform_curve(&edge.curve, motion)?, edge.t0, edge.t1),
388 );
389 continue;
390 }
391
392 // An edge with only ONE carrier — a seam meridian a face uses twice, or
393 // a degenerate apex point — has no carrier PAIR to re-intersect. It
394 // lies on that one carrier and moves exactly as the carrier does, and
395 // the corners it ends on re-trim it. This is the arm a bore's own seam
396 // takes whenever the cap it ends against is the thing that moved.
397 let carrier_pair = match second {
398 Some(second) if second != first => Some(second),
399 _ => None,
400 };
401 let (curve, t0, t1) = match carrier_pair {
402 None => {
403 let carried = if moves_carrier.contains(&first) {
404 transform_curve(&edge.curve, motion)?
405 } else {
406 edge.curve.clone()
407 };
408 // A SEAM that has to GROW. The carrier is unbounded here, but
409 // the stored seam curve is not: it spans whatever extent the
410 // face happened to be trimmed to, and a corner the motion
411 // carries past that end projects onto the end instead of onto
412 // the seam. That is not a limit of the geometry — a cylinder's
413 // generatrix is a straight line and it runs as far as its
414 // carrier does — so a STRAIGHT seam is rebuilt as the segment
415 // between its own two re-solved corners, which both lie on the
416 // carrier by construction.
417 //
418 // MEASURED, and it is why this is here rather than in a later
419 // slice: pushing a holed cap along the bore's own axis builds
420 // to `s = 10` on the old road, and refused here from
421 // `s ≈ 1.250` — the magnitude at which the cap passes the end
422 // of the bore's stored generatrix. The refusal quoted
423 // `3.000e0`, which is exactly that overrun at `s = 2`. The
424 // capability matrix's own cell for it sits at `s = 1`, where
425 // both roads agree, so the cell table could not see it and the
426 // swept column is what found it.
427 let curve = if carried.straight_segment(tolerance).is_some() {
428 make_line(start_new, end_new)?
429 } else {
430 carried
431 };
432 let t0 = parameter_on(&curve, start_new, tolerance, edge.id, "start")?;
433 let t1 = parameter_on(&curve, end_new, tolerance, edge.id, "end")?;
434 if (t1 - t0).abs() <= 1e-12 {
435 return Err(format!(
436 "{OP}: the corners of the single-carrier edge {} land on the same point \
437 of it — refusing",
438 edge.id
439 ));
440 }
441 (curve, t0, t1)
442 }
443 Some(second) => rebuild_edge(
444 edge,
445 carrier_of(&carriers, first)?,
446 carrier_of(&carriers, second)?,
447 start_new,
448 end_new,
449 tolerance,
450 )?,
451 };
452 rebuilt.insert(edge.id, (curve, t0, t1));
453 }
454
455 // The two guards the translation road's category-3 refusals are made of,
456 // stated on the rebuilt chord rather than on a plan: a moved face that
457 // lands exactly on its neighbour collapses an edge, and one that passes
458 // beyond it reverses one. Both are read from the geometry, so they hold
459 // for any carrier pair and not only for the straight rebuilds the old road
460 // could check.
461 for edge in &solid.edges {
462 let Some((curve, t0, t1)) = rebuilt.get(&edge.id) else {
463 continue;
464 };
465 if edge.start_vertex_id == edge.end_vertex_id || edge.degenerate {
466 continue;
467 }
468 let old_start = edge.curve.evaluate(edge.t0)?;
469 let old_end = edge.curve.evaluate(edge.t1)?;
470 let new_start = curve.evaluate(*t0)?;
471 let new_end = curve.evaluate(*t1)?;
472 let old_chord = old_end.sub(old_start);
473 let new_chord = new_end.sub(new_start);
474 if new_chord.length() <= tolerance {
475 return Err(format!(
476 "{OP}: the translation collapses edge {} to zero length (a moved face lands \
477 exactly on its neighbour)",
478 edge.id
479 ));
480 }
481 if old_chord.length() > tolerance && new_chord.dot(old_chord) < 0.0 {
482 return Err(format!(
483 "{OP}: the translation inverts edge {} (a moved face passes beyond its \
484 neighbour)",
485 edge.id
486 ));
487 }
488 }
489
490 for edge in &mut result.edges {
491 if let Some((curve, t0, t1)) = rebuilt.get(&edge.id) {
492 edge.curve = curve.clone();
493 edge.t0 = *t0;
494 edge.t1 = *t1;
495 }
496 }
497 for vertex in &mut result.vertices {
498 if let Some(point) = new_vertex.get(&vertex.id) {
499 vertex.point = *point;
500 }
501 }
502
503 // --- 3b. The trims -----------------------------------------------------
504 // Every face the rebuild touched regrows its carrier around its own new
505 // boundary and refits every pcurve on it. A MOVED face's carrier is the
506 // moved one; a FIXED face's is its own, GROWN to cover wherever the new
507 // boundary went — which is the same "extend the neighbour" the delete/heal
508 // road has always done, and is what makes "the pushed cap does not re-cross
509 // fixed edge N within its span" not a thing that can be said here.
510 let final_edges: HashMap<u64, EdgeRecord> =
511 result.edges.iter().map(|edge| (edge.id, edge.clone())).collect();
512 let mut touched_faces: Vec<(usize, usize)> = Vec::new();
513 for (shell_index, shell) in result.shells.iter().enumerate() {
514 for (face_index, face) in shell.faces.iter().enumerate() {
515 let face_dirty = moves_carrier.contains(&face.id)
516 || face.loops.iter().any(|loop_record| {
517 loop_record.coedges.iter().any(|c| dirty.contains(&c.edge_id))
518 });
519 if face_dirty {
520 touched_faces.push((shell_index, face_index));
521 }
522 }
523 }
524 for (shell_index, face_index) in touched_faces {
525 let face = &mut result.shells[shell_index].faces[face_index];
526 if moves_carrier.contains(&face.id) {
527 face.surface = transform_surface(&face.surface, motion)?;
528 }
529 regrow_and_refit_carrier(face, &final_edges, scale, OP)?;
530 }
531
532 // --- 4. Acceptance -----------------------------------------------------
533 // Unchanged from the old road, deliberately: this slice does not get to
534 // move the bar it is measured against.
535 let issues = result.validate();
536 if !issues.is_empty() {
537 return Err(format!("{OP}: moved solid failed validation: {issues:?}"));
538 }
539 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
540 if before * after <= 0.0 {
541 return Err(format!(
542 "{OP}: the translation inverts the solid (signed volume changed sign) — refusing"
543 ));
544 }
545 }
546 Ok(result)
547}
548
549/// Does the motion map this face's carrier onto ITSELF?
550///
551/// Sixteen points of the face's own patch, carried by the motion and projected
552/// back onto the carrier they came from (the STRETCHED one, so a sample that
553/// leaves the face's own trim is still asked about the surface rather than
554/// about the patch's edge). Nothing here reads the surface's type, so it
555/// answers for a plane slid in itself, a cylinder or cone pushed along its
556/// axis, a sphere or torus translated along nothing at all, and a fitted
557/// free-form patch that happens to be periodic, by the same arithmetic.
558fn carrier_invariant_under_motion(
559 patch: &NurbsSurface,
560 carrier: &NurbsSurface,
561 motion: AffineTransform,
562 tolerance: f64,
563) -> Result<bool, String> {
564 const STATIONS: usize = 4;
565 let [u0, u1] = patch.domain_u()?;
566 let [v0, v1] = patch.domain_v()?;
567 for i in 0..STATIONS {
568 for j in 0..STATIONS {
569 let u = u0 + (u1 - u0) * i as f64 / (STATIONS - 1) as f64;
570 let v = v0 + (v1 - v0) * j as f64 / (STATIONS - 1) as f64;
571 let carried = motion.point(patch.evaluate(u, v)?);
572 let foot = crate::project_point_to_surface(carrier, carried)?;
573 if foot.point.sub(carried).length() > tolerance {
574 return Ok(false);
575 }
576 }
577 }
578 Ok(true)
579}
580
581/// A face the way the user picked it: its persistent name where it has one,
582/// and its id either way so a log can still be followed.
583fn face_label(
584 solid: &BrepSolid,
585 face_lookup: &HashMap<u64, (usize, usize)>,
586 face_id: u64,
587) -> String {
588 match face_lookup
589 .get(&face_id)
590 .and_then(|&(shell, face)| solid.shells[shell].faces[face].name.as_deref())
591 {
592 Some(name) => format!("`{name}` (face {face_id})"),
593 None => format!("face {face_id}"),
594 }
595}
596
597fn carrier_of<'a>(
598 carriers: &'a HashMap<u64, NurbsSurface>,
599 face_id: u64,
600) -> Result<&'a NurbsSurface, String> {
601 carriers
602 .get(&face_id)
603 .ok_or_else(|| format!("{OP}: no carrier for face {face_id}"))
604}
605
606fn point_of(solid: &BrepSolid, vertex_id: u64) -> Vec3 {
607 solid
608 .vertices
609 .iter()
610 .find(|vertex| vertex.id == vertex_id)
611 .map(|vertex| vertex.point)
612 .unwrap_or_default()
613}
614
615fn translate_affine(translation: Vec3) -> Result<AffineTransform, String> {
616 AffineTransform::new([
617 1.0, 0.0, 0.0, translation.x, //
618 0.0, 1.0, 0.0, translation.y, //
619 0.0, 0.0, 1.0, translation.z, //
620 0.0, 0.0, 0.0, 1.0,
621 ])
622}
623
624/// The centre and radius of a ball containing every vertex of the body — the
625/// scale at which "cover the rest of the solid" is measured.
626fn body_ball(solid: &BrepSolid) -> Result<(Vec3, f64), String> {
627 if solid.vertices.is_empty() {
628 return Err(format!("{OP}: the body has no vertices"));
629 }
630 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
631 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
632 for vertex in &solid.vertices {
633 low = Vec3::new(
634 low.x.min(vertex.point.x),
635 low.y.min(vertex.point.y),
636 low.z.min(vertex.point.z),
637 );
638 high = Vec3::new(
639 high.x.max(vertex.point.x),
640 high.y.max(vertex.point.y),
641 high.z.max(vertex.point.z),
642 );
643 }
644 let centre = low.add(high).scale(0.5);
645 let radius = high.sub(low).length() * 0.5;
646 Ok((centre, radius.max(1.0)))
647}
648
649/// A face's carrier with its trim thrown away — stretched to cover a ball of
650/// radius `reach` about `centre`, which is the body and everywhere the motion
651/// can take it.
652///
653/// This is the step the operator's instruction names. A plane rebuilds as a
654/// patch that covers the ball; a cylinder, cone or fillet band prolongs along
655/// its own axis with the SAME frame and the SAME generatrix direction, so the
656/// stretch is exact and not a fit; a sphere and a torus are already complete
657/// and are returned untouched. A free-form patch is returned as it stands — it
658/// has no exact prolongation, and inventing one would be extrapolating a fit,
659/// so a section that needs more of it refuses downstream instead.
660fn unbounded_carrier(
661 surface: &NurbsSurface,
662 centre: Vec3,
663 reach: f64,
664 plane_tolerance: f64,
665) -> Result<NurbsSurface, String> {
666 match surface.analytic() {
667 Some(AnalyticSurface::Sphere { .. }) | Some(AnalyticSurface::Torus { .. }) => {
668 return Ok(surface.clone())
669 }
670 Some(AnalyticSurface::RuledRevolution {
671 frame,
672 rho0,
673 rho1,
674 height,
675 }) => {
676 let (frame, rho0, rho1, height) = (frame.clone(), *rho0, *rho1, *height);
677 return stretch_ruled(&frame, rho0, rho1, height, centre, reach);
678 }
679 Some(AnalyticSurface::Revolution {
680 frame,
681 sweep,
682 generatrix,
683 ..
684 }) => {
685 // A partial sweep with a STRAIGHT generatrix is a cylinder or cone
686 // band (this is how a fillet band recognizes). Its prolongation is
687 // the same one, over the same sweep.
688 let sweep = *sweep;
689 let frame = frame.clone();
690 if let Some((start, end)) = generatrix.straight_segment(plane_tolerance) {
691 let (rho0, z0) = axial_radial(&frame, start);
692 let (rho1, z1) = axial_radial(&frame, end);
693 let height = z1 - z0;
694 if height.abs() > 1e-12 {
695 let base = RevolutionFrame {
696 origin: frame.origin.add(frame.axis.scale(z0)),
697 ..frame.clone()
698 };
699 return stretch_ruled_sweep(&base, rho0, rho1, height, centre, reach, sweep);
700 }
701 }
702 return Ok(surface.clone());
703 }
704 _ => {}
705 }
706 // A plane — tagged or merely geometric — rebuilds as a patch covering the
707 // ball. Everything else (a fitted free-form patch) keeps its own domain.
708 match plane_of_surface(surface, plane_tolerance, OP) {
709 Ok(plane) => {
710 let foot = centre.sub(plane.normal.scale(centre.sub(plane.origin).dot(plane.normal)));
711 let origin = foot
712 .sub(plane.u_dir.scale(reach))
713 .sub(plane.v_dir.scale(reach));
714 crate::make_plane(origin, plane.u_dir, plane.v_dir, 2.0 * reach, 2.0 * reach)
715 }
716 Err(_) => Ok(surface.clone()),
717 }
718}
719
720/// A point in the frame's own cylindrical reading: its radius from the axis
721/// and its height along it. `RevolutionFrame::cylindrical` says the same thing
722/// and is private to the geometry module.
723fn axial_radial(frame: &RevolutionFrame, point: Vec3) -> (f64, f64) {
724 let delta = point.sub(frame.origin);
725 let z = delta.dot(frame.axis);
726 (delta.sub(frame.axis.scale(z)).length(), z)
727}
728
729fn stretch_ruled(
730 frame: &RevolutionFrame,
731 rho0: f64,
732 rho1: f64,
733 height: f64,
734 centre: Vec3,
735 reach: f64,
736) -> Result<NurbsSurface, String> {
737 stretch_ruled_sweep(
738 frame,
739 rho0,
740 rho1,
741 height,
742 centre,
743 reach,
744 std::f64::consts::TAU,
745 )
746}
747
748/// Prolong a straight generatrix along the axis so the carrier spans the ball,
749/// and revolve it again through the same sweep. Exact: the frame, the axis and
750/// the generatrix's slope are the carrier's own.
751fn stretch_ruled_sweep(
752 frame: &RevolutionFrame,
753 rho0: f64,
754 rho1: f64,
755 height: f64,
756 centre: Vec3,
757 reach: f64,
758 sweep: f64,
759) -> Result<NurbsSurface, String> {
760 if height.abs() <= 1e-12 {
761 return Err(format!("{OP}: a ruled carrier with no height cannot be stretched"));
762 }
763 let axial = centre.sub(frame.origin).dot(frame.axis);
764 let low = (axial - reach).min(0.0);
765 let high = (axial + reach).max(height);
766 let rho_at = |z: f64| rho0 + (rho1 - rho0) * z / height;
767 // A cone's generatrix crosses its own apex: prolonging past it would fold
768 // the carrier through itself and give a second nappe with a negative
769 // radius. Stop at the apex, which is where the carrier genuinely ends.
770 let (low, high) = if (rho1 - rho0).abs() > 1e-12 {
771 let apex_z = -rho0 * height / (rho1 - rho0);
772 if apex_z > 0.0 {
773 (low.max(apex_z), high)
774 } else {
775 (low, high.min(apex_z).max(low))
776 }
777 } else {
778 (low, high)
779 };
780 let (low, high) = if high - low > 1e-12 * height.abs() {
781 (low, high)
782 } else {
783 (0.0, height)
784 };
785 let base = frame.origin.add(frame.axis.scale(low));
786 let start = base.add(frame.x_axis.scale(rho_at(low)));
787 let end = frame
788 .origin
789 .add(frame.axis.scale(high))
790 .add(frame.x_axis.scale(rho_at(high)));
791 let generatrix = make_line(start, end)?;
792 make_revolution(base, frame.axis, &generatrix, sweep)
793}
794
795/// Where a corner goes: as near the rigid guess `seed` as a point can be while
796/// lying on every carrier in `at`.
797///
798/// Each carrier is linearized at the current iterate's foot — the tangent plane
799/// `n·(x − q) = 0` — and the correction from the seed is the damped
800/// least-squares solution of the stacked system. Three transverse carriers give
801/// the unique triple point; a rank-deficient set (a rim's two carriers, which
802/// meet in a CURVE) leaves a free direction along which the solution stays
803/// where the seed put it, which is exactly the coherent answer: the corner
804/// rides the rim the way the motion carried it rather than sliding to the
805/// nearest point of the new rim, which for a translated bore would flip it to
806/// the far side.
807fn solve_corner(
808 at: &[&NurbsSurface],
809 seed: Vec3,
810 tolerance: f64,
811) -> Result<Vec3, String> {
812 // Enough to make the 3×3 normal matrix invertible in the directions no
813 // carrier constrains, and small enough that the bias it leaves at the fixed
814 // point (≈ λ·|correction|) is orders below the residual gate.
815 const DAMPING: f64 = 1e-12;
816 let mut x = seed;
817 let mut worst = f64::INFINITY;
818 for _ in 0..64 {
819 let mut normal_matrix = [[0.0f64; 3]; 3];
820 let mut rhs = [0.0f64; 3];
821 worst = 0.0;
822 for surface in at {
823 let projection = crate::project_point_to_surface(surface, x)?;
824 let foot = projection.point;
825 let normal = surface.normal(projection.u, projection.v)?;
826 worst = worst.max(foot.sub(x).length());
827 // n·(seed + d) = n·foot ⇒ n·d = n·(foot − seed)
828 let row = [normal.x, normal.y, normal.z];
829 let value = normal.dot(foot.sub(seed));
830 for i in 0..3 {
831 for j in 0..3 {
832 normal_matrix[i][j] += row[i] * row[j];
833 }
834 rhs[i] += row[i] * value;
835 }
836 }
837 if worst <= tolerance {
838 return Ok(x);
839 }
840 // Damping is what lets a RANK-DEFICIENT corner — a rim's two carriers,
841 // which meet in a curve — keep the seed's position along the direction
842 // nothing constrains. It is also a bias of order `λ·|correction|` on
843 // the answer, so it is spent only where it buys something: three
844 // transverse carriers determine the corner outright and are solved
845 // exactly. (Measured: with the damping always on, a three-face corner
846 // lift came out 1.4e-13 against its closed form instead of 1e-16.)
847 let scale3 = (normal_matrix[0][0] + normal_matrix[1][1] + normal_matrix[2][2]) / 3.0;
848 let full_rank = determinant3(&normal_matrix).abs() > 1e-9 * scale3.powi(3).max(1e-300);
849 if !full_rank {
850 for i in 0..3 {
851 normal_matrix[i][i] += DAMPING * scale3.max(1.0);
852 }
853 }
854 let delta = solve3(&normal_matrix, &rhs)
855 .ok_or_else(|| "the carriers give no correction".to_string())?;
856 let next = seed.add(Vec3::new(delta[0], delta[1], delta[2]));
857 if next.sub(x).length() <= tolerance * 1e-3 {
858 x = next;
859 break;
860 }
861 x = next;
862 }
863 // One last reading, so the refusal quotes the distance rather than the
864 // iteration count.
865 let mut residual = 0.0f64;
866 for surface in at {
867 let projection = crate::project_point_to_surface(surface, x)?;
868 residual = residual.max(projection.point.sub(x).length());
869 }
870 if residual <= tolerance * RESIDUAL_SLACK {
871 return Ok(x);
872 }
873 // Say WHICH of the two ways it failed, because they want different things
874 // from the reader. If the carriers' normals all agree at the corner they
875 // share a tangent plane, and three tangent surfaces constrain a point in
876 // ONE direction and pin nothing in the other two — no motion of any size
877 // has an answer there, and the reader needs to know it is the shape and
878 // not the distance. If they are transverse and still do not meet, it is
879 // the motion that carried them apart, and a smaller one will work.
880 let mut spread: f64 = 0.0;
881 let mut normals: Vec<Vec3> = Vec::with_capacity(at.len());
882 for surface in at {
883 let projection = crate::project_point_to_surface(surface, x)?;
884 normals.push(surface.normal(projection.u, projection.v)?);
885 }
886 for (index, first) in normals.iter().enumerate() {
887 for second in &normals[index + 1..] {
888 let sine = first.cross(*second).length();
889 let cosine = first.dot(*second).abs();
890 spread = spread.max(sine.atan2(cosine));
891 }
892 }
893 if spread <= 1e-6 {
894 return Err(format!(
895 "they share a common TANGENT PLANE there (their normals agree to {spread:.1e} rad), \
896 so together they fix the corner in one direction and pin it in neither of the \
897 other two; the nearest point to all of them is still {residual:.3e} away"
898 ));
899 }
900 Err(format!(
901 "the nearest point to all of them is {residual:.3e} off at least one, against a \
902 tolerance of {:.3e} (their normals there are {spread:.3e} rad apart, so they are \
903 transverse and it is the MOTION that carried them apart); worst reading while \
904 iterating {worst:.3e}",
905 tolerance * RESIDUAL_SLACK
906 ))
907}
908
909/// Solve a symmetric 3×3 system by Cramer's rule. `None` when it is singular
910/// even after damping, which means the carriers constrain nothing at all.
911fn solve3(matrix: &[[f64; 3]; 3], rhs: &[f64; 3]) -> Option<[f64; 3]> {
912 let det = determinant3(matrix);
913 if det.abs() <= f64::MIN_POSITIVE {
914 return None;
915 }
916 let mut out = [0.0f64; 3];
917 for column in 0..3 {
918 let mut replaced = *matrix;
919 for row in 0..3 {
920 replaced[row][column] = rhs[row];
921 }
922 out[column] = determinant3(&replaced) / det;
923 }
924 if out.iter().all(|value| value.is_finite()) {
925 Some(out)
926 } else {
927 None
928 }
929}
930
931fn determinant3(m: &[[f64; 3]; 3]) -> f64 {
932 m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
933 - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
934 + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
935}
936
937/// Rebuild one edge as the section of its own two carriers, delimited by its
938/// own two re-solved corners.
939fn rebuild_edge(
940 edge: &EdgeRecord,
941 first: &NurbsSurface,
942 second: &NurbsSurface,
943 start_new: Vec3,
944 end_new: Vec3,
945 tolerance: f64,
946) -> Result<(NurbsCurve, f64, f64), String> {
947 let section = carrier_section(edge, first, second, tolerance)?;
948 match section {
949 Section::Straight => {
950 let curve = make_line(start_new, end_new)?;
951 Ok((curve, 0.0, 1.0))
952 }
953 Section::Curve(curve) => {
954 let start_t = parameter_on(&curve, start_new, tolerance, edge.id, "start")?;
955 let end_t = parameter_on(&curve, end_new, tolerance, edge.id, "end")?;
956 if (end_t - start_t).abs() <= 1e-12 {
957 return Err(format!(
958 "{OP}: the corners of edge {} land on the same point of their section \
959 — refusing",
960 edge.id
961 ));
962 }
963 if start_t < end_t {
964 Ok((curve, start_t, end_t))
965 } else {
966 let reversed = curve.reversed()?;
967 let [d0, d1] = reversed.domain()?;
968 // `reversed` maps t ↦ d0 + d1 − t on the same domain.
969 Ok((reversed, d0 + d1 - start_t, d0 + d1 - end_t))
970 }
971 }
972 }
973}
974
975/// The section of two carriers, chosen to run near the boundary it replaces.
976fn carrier_section(
977 edge: &EdgeRecord,
978 first: &NurbsSurface,
979 second: &NurbsSurface,
980 tolerance: f64,
981) -> Result<Section, String> {
982 let plane_tolerance = tolerance * 10.0;
983 if plane_of_surface(first, plane_tolerance, OP).is_ok()
984 && plane_of_surface(second, plane_tolerance, OP).is_ok()
985 {
986 return Ok(Section::Straight);
987 }
988 let seeds = edge_seeds(edge, 9)?;
989 let policy = MarchPolicy {
990 tolerance,
991 residual_tolerance: tolerance * 100.0,
992 seeds: seeds.clone(),
993 };
994 let found = match reintersect_carriers(first, second, &policy) {
995 Ok(found) => found,
996 Err(ReintersectRefusal::Separated) => {
997 return Err(format!(
998 "{OP}: the carriers of edge {} no longer meet after the motion — refusing",
999 edge.id
1000 ))
1001 }
1002 Err(other) => {
1003 return Err(format!("{OP}: edge {}: {}", edge.id, other.describe()));
1004 }
1005 };
1006 let section = found
1007 .nearest_section(&seeds)
1008 .map_err(|error| format!("{OP}: edge {}: {error}", edge.id))?;
1009 Ok(Section::Curve(section.curve.clone()))
1010}
1011
1012/// The parameter at which `curve` passes through `point`, refusing rather than
1013/// snapping when it does not pass through it at all.
1014fn parameter_on(
1015 curve: &NurbsCurve,
1016 point: Vec3,
1017 tolerance: f64,
1018 edge_id: u64,
1019 which: &str,
1020) -> Result<f64, String> {
1021 let projection = project_point_to_curve(curve, point)?;
1022 let off = projection.point.sub(point).length();
1023 if off > tolerance * RESIDUAL_SLACK {
1024 return Err(format!(
1025 "{OP}: the re-solved {which} corner of edge {edge_id} is {off:.3e} off the section \
1026 its carriers make — refusing"
1027 ));
1028 }
1029 Ok(projection.u)
1030}
1031
1032
1033// ===========================================================================
1034// THE ROUTING READING — which cells need the REBUILD road, measured rather
1035// than enumerated.
1036// ===========================================================================
1037
1038/// What a Move Face would have to do to the TOPOLOGY, read off the motion
1039/// before anything is built.
1040#[derive(Debug, Clone, Default)]
1041pub struct RouteReading {
1042 /// Faces that do not bound the moved group today and whose trimmed region
1043 /// a moved carrier would pass through — the faces that would have to
1044 /// receive a loop they do not have.
1045 pub new_loop_faces: Vec<(u64, Option<String>)>,
1046 /// Faces whose section against a moved carrier could not be COMPUTED.
1047 ///
1048 /// These are not "no", they are "unknown", and they are reported rather
1049 /// than folded into the negative: an intersection that fails silently
1050 /// would make this predicate answer NO for a face it never managed to ask
1051 /// about, which is the difference between a measurement and an instrument
1052 /// that cannot report a negative. A caller that routes on this must treat
1053 /// a non-empty list as a refusal rather than as a clean read.
1054 pub unreadable: Vec<(u64, Option<String>, String)>,
1055}
1056
1057impl RouteReading {
1058 /// The routing decision itself: a motion that would put a boundary on a
1059 /// face that has none cannot be answered by re-solving the existing
1060 /// topology's geometry, because there is no loop there to re-solve.
1061 pub fn needs_rebuild(&self) -> bool {
1062 !self.new_loop_faces.is_empty()
1063 }
1064
1065 /// Whether every face was actually ASKED. A reading with unreadable faces
1066 /// has not established the negative for those faces.
1067 pub fn is_complete(&self) -> bool {
1068 self.unreadable.is_empty()
1069 }
1070}
1071
1072/// Which faces a moved selection's carriers would open a NEW hole in.
1073///
1074/// # What this measures, and why it is not a case table
1075///
1076/// The carrier road re-solves the EXISTING topology's geometry against the
1077/// moved carriers. That is right for as long as the moved group keeps the
1078/// neighbours it has: every boundary it owns is a loop that is already there,
1079/// and re-solving moves it. It is wrong the moment the motion carries a
1080/// boundary onto a face the group does not currently touch, because there is
1081/// no loop on that face to re-solve — which is the migrating mouth, and it is
1082/// why that cell refuses on both roads today.
1083///
1084/// So the question "does this motion need the rebuild road" is exactly "does
1085/// some moved carrier, after the motion, pass through the TRIMMED REGION of a
1086/// face it does not currently bound". It reads no surface type and no
1087/// configuration; a plane, a bore wall and an imported free-form patch are all
1088/// asked the same question.
1089///
1090/// # The two halves, and which one already existed
1091///
1092/// The SECTION half is the kernel's own `intersect_surfaces`. Note that
1093/// [`carrier_section`] above is NOT usable here: it seeds its march from the
1094/// edge it is replacing (`edge_seeds`), and the whole point of this question is
1095/// faces that share no edge with the moved group, so there is no seed to take.
1096///
1097/// The TRIMMED-REGION half is `parameter_point_in_face`, and nothing on the
1098/// carrier road called it before this: the road only ever asked about carriers
1099/// it already had an edge on, where containment is not in question. A section
1100/// that crosses a face's CARRIER but misses its trim is the ordinary case — a
1101/// bore sliding along inside its own slab passes through the plane of every
1102/// wall in the body and opens a hole in none of them — so the trim test is
1103/// what separates a migration from a motion that merely moves.
1104///
1105/// Faces the group ALREADY bounds are excluded: a section there is the first
1106/// half's job and is not a topology change.
1107pub fn route_reading(
1108 solid: &BrepSolid,
1109 moved: &[u64],
1110 motion: &AffineTransform,
1111) -> Result<RouteReading, String> {
1112 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1113 let tolerance = tolerances.model.max(1e-9);
1114 let scale = crate::solid_scale(solid).max(1.0);
1115 let moved_set: HashSet<u64> = moved.iter().copied().collect();
1116
1117 // Faces the moved group already bounds: they share an edge with it, so a
1118 // section there is the re-solve's business rather than a new loop.
1119 let mut adjacent: HashSet<u64> = HashSet::default();
1120 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
1121 if moved_set.contains(&face.id) {
1122 continue;
1123 }
1124 let touches = face.loops.iter().flat_map(|wire| &wire.coedges).any(|use_| {
1125 solid
1126 .shells
1127 .iter()
1128 .flat_map(|shell| &shell.faces)
1129 .filter(|other| moved_set.contains(&other.id))
1130 .any(|other| {
1131 other
1132 .loops
1133 .iter()
1134 .flat_map(|wire| &wire.coedges)
1135 .any(|theirs| theirs.edge_id == use_.edge_id)
1136 })
1137 });
1138 if touches {
1139 adjacent.insert(face.id);
1140 }
1141 }
1142
1143 let mut centre = Vec3::new(0.0, 0.0, 0.0);
1144 for vertex in &solid.vertices {
1145 centre = centre.add(vertex.point);
1146 }
1147 centre = centre.scale(1.0 / (solid.vertices.len().max(1)) as f64);
1148 // HYGIENE, NOT THE FIX. The carrier only has to reach the body and
1149 // wherever the motion takes it; `scale * 4` stretched it four times beyond
1150 // the body for no reason. Bounding does not make a section findable — the
1151 // seeds do that — so this is tidiness.
1152 let travel = motion.point(centre).sub(centre).length();
1153 let reach = scale + travel + tolerance * 10.0;
1154
1155 // The carriers BEFORE and AFTER the motion, both prolonged the same way so
1156 // the two readings differ only by the motion.
1157 let mut before_carriers: Vec<NurbsSurface> = Vec::new();
1158 let mut after_carriers: Vec<NurbsSurface> = Vec::new();
1159 for face_id in moved {
1160 let Some((shell, position)) = find_face(solid, *face_id) else {
1161 return Err(format!("route_reading: no face with id {face_id}"));
1162 };
1163 let surface = &solid.shells[shell].faces[position].surface;
1164 let unbounded = unbounded_carrier(surface, centre, reach, tolerance * 10.0)?;
1165 after_carriers.push(transform_surface(&unbounded, *motion)?);
1166 before_carriers.push(unbounded);
1167 }
1168
1169 let mut unreadable: Vec<(u64, Option<String>, String)> = Vec::new();
1170 let candidates: Vec<&FaceRecord> = solid
1171 .shells
1172 .iter()
1173 .flat_map(|shell| &shell.faces)
1174 .filter(|face| !moved_set.contains(&face.id) && !adjacent.contains(&face.id))
1175 .collect();
1176
1177 let before = crossed_faces(
1178 &before_carriers,
1179 &candidates,
1180 scale,
1181 tolerance,
1182 &mut unreadable,
1183 );
1184 let after = crossed_faces(
1185 &after_carriers,
1186 &candidates,
1187 scale,
1188 tolerance,
1189 &mut unreadable,
1190 );
1191
1192 // THE DISCRIMINATOR: the crossing has to be NEW.
1193 //
1194 // "The moved carrier crosses this face's trim" is satisfied by any
1195 // prolonged planar carrier against any wall of the body — an unbounded
1196 // plane crosses everything, which is what unbounded means. Measured: it
1197 // fired on `pocket-floor-down` and `pocket-wall-out`, which merely deepen
1198 // and widen a pocket and put no new hole anywhere.
1199 //
1200 // What separates a migration from that is whether the crossing EXISTED
1201 // BEFORE. A pocket floor's horizontal carrier already crossed the body's
1202 // vertical walls; moving it down changes nothing about that, and what kept
1203 // the face off those walls was its own trim, which the re-solve maintains.
1204 // A bore at `x = 12` does NOT cross a step floor spanning `x ∈ [30, 60]`,
1205 // and after a 30 mm slide it does. That is the migration, and it is the
1206 // only thing the existing topology has no loop to re-solve.
1207 //
1208 // Chosen over "the section must lie inside the moved face's own trim,
1209 // transported by the motion", which also fixes the pocket cells and reads
1210 // well, because that rule fails on a migration into a THICKER region: move
1211 // the same bore from the thin side of the step to the thick side and its
1212 // new mouth lands beyond the transported trim, so it would go quiet. No
1213 // cell in the matrix exercises that, so it would have looked clean and
1214 // been wrong — the same shape as the `0 LOST` reading that was true of the
1215 // cells and false of the road.
1216 //
1217 // A differential test is also harder to fool than an absolute one: the
1218 // BEFORE and AFTER readings share every source of error except the motion,
1219 // which is why the before pass is seeded identically. An unseeded before
1220 // pass would report a spurious empty from the search blindness above and
1221 // then fire on everything.
1222 //
1223 // # THE KNOWN LIMIT OF THIS DISCRIMINATOR
1224 //
1225 // It goes quiet whenever the UNMOVED carrier ALREADY sectioned the face's
1226 // trim, because then the crossing is not new — even if no material was
1227 // there and a mouth appears anyway. The case to look at is a bore moved
1228 // OBLIQUELY whose unbounded cylinder already clips the target floor's trim
1229 // at the start while the bore itself does not yet reach it: before
1230 // non-empty, after non-empty, quiet, and a mouth opens. `step-bore-across`
1231 // escapes it only because `x = 12` is genuinely outside `x ∈ [30, 60]`; a
1232 // cell starting nearer the step would show it.
1233 //
1234 // Stated rather than fixed: nothing in the 54 cells exercises it, so there
1235 // is no measurement to fix it against, and a rule tuned against a case
1236 // nobody has measured is how a predicate stops meaning anything.
1237 let mut new_loop_faces: Vec<(u64, Option<String>)> = candidates
1238 .iter()
1239 .filter(|face| after.contains(&face.id) && !before.contains(&face.id))
1240 .map(|face| (face.id, face.name.clone()))
1241 .collect();
1242
1243 new_loop_faces.sort();
1244 unreadable.sort();
1245 unreadable.dedup();
1246 Ok(RouteReading {
1247 new_loop_faces,
1248 unreadable,
1249 })
1250}
1251
1252/// Which of `candidates` have a carrier's section running through their own
1253/// TRIMMED region.
1254///
1255/// Seeded from the FACE rather than the carrier, because the carrier is
1256/// unbounded by construction and `intersect_surfaces` returns the same
1257/// `Ok(empty)` for "the bounds prove there is no section" as for "the grid
1258/// search did not find one". Measured on `stepped2`: a prolonged carrier
1259/// against two parallel planes of the same kind, 18 apart and at near-identical
1260/// distances along it, is found on one at every seed density tried (1, 8, 40)
1261/// and on the other at none — while an explicit seed finds both. Every other
1262/// in-kernel caller of `intersect_surfaces` seeds for the same reason.
1263///
1264/// The seeds are the nearest face samples PROJECTED onto the carrier. A grid
1265/// sample will essentially never land on the section — the section is a curve
1266/// and the samples are a lattice — so a seed has to be near enough to REFINE
1267/// rather than already correct.
1268fn crossed_faces(
1269 carriers: &[NurbsSurface],
1270 candidates: &[&FaceRecord],
1271 scale: f64,
1272 tolerance: f64,
1273 unreadable: &mut Vec<(u64, Option<String>, String)>,
1274) -> HashSet<u64> {
1275 let mut crossed: HashSet<u64> = HashSet::default();
1276 for face in candidates {
1277 for carrier in carriers {
1278 let mut nearest: Vec<(f64, Vec3)> = Vec::new();
1279 if let (Ok([u0, u1]), Ok([v0, v1])) = (face.surface.domain_u(), face.surface.domain_v())
1280 {
1281 for ui in 0..=10 {
1282 for vi in 0..=10 {
1283 let u = u0 + (u1 - u0) * ui as f64 / 10.0;
1284 let v = v0 + (v1 - v0) * vi as f64 / 10.0;
1285 let Ok(point) = face.surface.evaluate(u, v) else {
1286 continue;
1287 };
1288 let Ok(projection) = crate::project_point_to_surface(carrier, point) else {
1289 continue;
1290 };
1291 if projection.distance <= scale * 0.1 {
1292 if let Ok(on_carrier) = carrier.evaluate(projection.u, projection.v) {
1293 nearest.push((projection.distance, on_carrier));
1294 }
1295 }
1296 }
1297 }
1298 }
1299 nearest.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1300 let options = crate::SurfaceIntersectionOptions {
1301 tolerance,
1302 seed_points: nearest.into_iter().take(12).map(|(_, p)| p).collect(),
1303 ..Default::default()
1304 };
1305 let curves = match crate::intersect_surfaces(carrier, &face.surface, &options) {
1306 Ok(curves) => curves,
1307 Err(error) => {
1308 // NOT a negative: record it so a caller can tell "not
1309 // crossed" from "never successfully asked".
1310 unreadable.push((face.id, face.name.clone(), error));
1311 continue;
1312 }
1313 };
1314 let mut opens = false;
1315 for curve in &curves {
1316 // `params_b` is the section's parameters on the FACE's own
1317 // surface, so the trim question is asked directly rather than
1318 // by projecting a 3D point back.
1319 for uv in &curve.params_b {
1320 let point = crate::Vec2 { x: uv[0], y: uv[1] };
1321 if matches!(
1322 crate::parameter_point_in_face(face, point, tolerance),
1323 Ok(crate::PolygonClass::Inside)
1324 ) {
1325 opens = true;
1326 break;
1327 }
1328 }
1329 if opens {
1330 break;
1331 }
1332 }
1333 if opens {
1334 crossed.insert(face.id);
1335 break;
1336 }
1337 }
1338 }
1339 crossed
1340}