brep_kernel/edit/direct_edit/face_offset.rs
1use super::*;
2
3// ---------------------------------------------------------------------------
4// Push a CURVED analytic face (cylinder / cone) by OFFSETTING its carrier.
5//
6// The methodology (user directive 2026-08-28, the architecture principle made
7// literal): a face is a TRIMMED region of an infinite carrier surface. To push
8// a curved face we OFFSET its untrimmed carrier surface, then re-derive the trim
9// as the INTERSECTIONS of that offset surface with the (unchanged) untrimmed
10// carriers of the neighbour faces — `intersect_analytic_pair` does the surface ∩
11// surface, `build_pcurve_on_surface` re-trims. This is the same engine the
12// boolean imprint uses; here one surface (the pushed face's) is replaced by its
13// offset and the incident edges are recut.
14//
15// Slice 1 scope (honest refusals, never a bad solid): the PUSHED face is a
16// cylinder or cone (`RuledRevolution`); its neighbours across each boundary edge
17// are PLANAR. Ruled/sphere/torus neighbours and free-form pushed faces are
18// deferred.
19// ---------------------------------------------------------------------------
20
21/// The exact analytic offset of a ruled-revolution CARRIER surface (cylinder or
22/// cone) by `signed_distance` along its OUTWARD normal — the untrimmed surface
23/// S′ whose re-intersection with the neighbour carriers gives the new trim.
24///
25/// A normal offset of a ruled revolution is another ruled revolution with the
26/// SAME axis and half-angle: in the (axial z, radial ρ) meridian, the generatrix
27/// line `ρ = rho0 + m·z` (m = dρ/dz = (rho1−rho0)/height; m = 0 for a cylinder)
28/// offsets to the PARALLEL line `ρ = rho0 + m·z + δ·√(1+m²)` — a uniform radial
29/// growth `grow = δ·√(1+m²)` at every z. So S′ is built by revolving the grown
30/// generatrix about the SAME frame, over the SAME axial span `[0, height]` and
31/// seam azimuth (`frame.x_axis`) as the source — the span keeps the neighbour
32/// caps IN the surface's domain (an axis-translation offset would slide the
33/// finite patch off them) and the shared seam meridian stays put.
34///
35/// `signed_distance > 0` grows the surface outward (radius increases). Refuses a
36/// carrier whose grown radius reaches/crosses the axis at either end.
37fn offset_ruled_carrier(
38 surface: &NurbsSurface,
39 signed_distance: f64,
40 tolerance: f64,
41) -> Result<NurbsSurface, String> {
42 let Some(AnalyticSurface::RuledRevolution {
43 frame,
44 rho0,
45 rho1,
46 height,
47 }) = surface.analytic()
48 else {
49 return Err("offset_ruled_carrier: face carrier is not a ruled revolution".into());
50 };
51 let (frame, rho0, rho1, height) = (frame.clone(), *rho0, *rho1, *height);
52 let slope = (rho1 - rho0) / height;
53 let grow = signed_distance * (1.0 + slope * slope).sqrt();
54 let rho0_new = rho0 + grow;
55 let rho1_new = rho1 + grow;
56 if rho0_new <= tolerance || rho1_new <= tolerance {
57 return Err(
58 "offset (push): the ruled carrier collapses to or past its axis — refusing".into(),
59 );
60 }
61 // Revolve the grown generatrix over the SAME axial span + seam azimuth.
62 let start = frame.origin.add(frame.x_axis.scale(rho0_new));
63 let end = frame
64 .origin
65 .add(frame.axis.scale(height))
66 .add(frame.x_axis.scale(rho1_new));
67 let generatrix = make_line(start, end)?;
68 make_revolution(frame.origin, frame.axis, &generatrix, std::f64::consts::TAU)
69}
70
71/// The face's OUTWARD unit normal at its parameter-domain midpoint, oriented by
72/// `same_sense` (the convention `push_face::planar_face_normal` uses).
73fn outward_normal_mid(face: &FaceRecord) -> Result<(Vec3, Vec3), String> {
74 let [u0, u1] = face.surface.domain_u()?;
75 let [v0, v1] = face.surface.domain_v()?;
76 let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
77 let point = face.surface.evaluate(um, vm)?;
78 let mut normal = face.surface.normal(um, vm)?;
79 if !face.same_sense {
80 normal = normal.scale(-1.0);
81 }
82 Ok((point, normal))
83}
84
85/// Push a CYLINDER or CONE face along its outward normal by `distance` (positive
86/// grows the solid) by OFFSETTING its carrier surface and re-deriving the trim as
87/// the intersection of that offset surface with the neighbour carriers.
88///
89/// Slice-1 scope (honest refusal, never a bad solid): a full-revolution
90/// cylinder/cone SIDE face whose neighbour across every non-seam boundary edge is
91/// PLANAR (its end caps). The pushed face's own periodic SEAM edge rides the
92/// offset carrier's meridian; each rim edge re-intersects its cap
93/// (`intersect_analytic_pair`, exact circle); the caps re-trim as planar faces.
94/// Ruled/curved neighbours and non-full-revolution ruled faces are deferred.
95pub fn offset_ruled_face(
96 solid: &BrepSolid,
97 face_id: u64,
98 distance: f64,
99) -> Result<BrepSolid, String> {
100 if !distance.is_finite() {
101 return Err("offset_ruled_face: distance must be finite".into());
102 }
103 let scale = solid_model_scale(solid);
104 let tolerance = (scale * 1e-7).max(1e-9);
105 let plane_tolerance = (scale * 1e-6).max(1e-7);
106
107 let (pshell, pface) =
108 find_face(solid, face_id).ok_or_else(|| format!("offset_ruled_face: no face {face_id}"))?;
109 let pushed = &solid.shells[pshell].faces[pface];
110 let Some(AnalyticSurface::RuledRevolution { frame, .. }) = pushed.surface.analytic() else {
111 return Err("offset_ruled_face: the pushed face is not a cylinder or cone".into());
112 };
113 let (frame_origin, frame_axis) = (frame.origin, frame.axis);
114
115 // Sign the offset: the carrier grows outward (radius +) along the face's
116 // OUTWARD normal. If that normal points radially inward (a hole wall), a
117 // positive push shrinks the radius.
118 let (mid_point, mid_normal) = outward_normal_mid(pushed)?;
119 let radial = {
120 let d = mid_point.sub(frame_origin);
121 d.sub(frame_axis.scale(d.dot(frame_axis)))
122 };
123 if radial.length() <= tolerance {
124 return Err("offset_ruled_face: degenerate radial direction (face on the axis)".into());
125 }
126 let outward_sign = if mid_normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
127 let signed_distance = distance * outward_sign;
128
129 let s_prime = offset_ruled_carrier(&pushed.surface, signed_distance, tolerance)?;
130
131 // Edge -> incident faces, to classify each of the pushed face's boundary
132 // edges as a SEAM (both incidences are the pushed face) or a RIM (the other
133 // face is the fixed neighbour cap).
134 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
135 for shell in &solid.shells {
136 for face in &shell.faces {
137 for loop_record in &face.loops {
138 for coedge in &loop_record.coedges {
139 faces_of_edge
140 .entry(coedge.edge_id)
141 .or_default()
142 .push(face.id);
143 }
144 }
145 }
146 }
147 let edge_by_id: HashMap<u64, &EdgeRecord> =
148 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
149
150 let mut new_curve: HashMap<u64, NurbsCurve> = HashMap::default();
151 let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
152 let mut cap_faces: HashSet<u64> = HashSet::default();
153 // Coaxial ruled neighbours (a stepped/telescoping bore or boss: a cylinder
154 // meeting a coaxial cone, or two coaxial cones) — retrimmed on their own
155 // (axially-grown) carriers rather than as planar caps.
156 let mut ruled_faces: HashSet<u64> = HashSet::default();
157 // CURVED neighbours re-intersected through the shared generic lane — a
158 // sphere dome, a fillet torus, a general revolution, a non-coaxial
159 // cylinder. Their carriers do NOT move and need no growth (unlike a plane's
160 // finite patch or a ruled band's axial span, a sphere's and a torus's
161 // domains are already closed over their whole surface, and a re-intersected
162 // rim by construction lands on the carrier it was intersected with), so
163 // they are re-trimmed in place: pcurves rebuilt around the moved rim, and
164 // every OTHER edge they own re-trimmed by parameter on the curve it already
165 // carries — see `retrim_curved_neighbour_ranges`.
166 let mut curved_faces: HashSet<u64> = HashSet::default();
167 let mut seam_edges: Vec<u64> = Vec::new();
168 // Current vertex positions, for rebuilding a ruled neighbour's seam whose
169 // far endpoint (its unmoved rim vertex) is not relocated by any rim.
170 let vertex_pos: HashMap<u64, Vec3> =
171 solid.vertices.iter().map(|v| (v.id, v.point)).collect();
172
173 // OPEN rim arcs (a multi-loop pushed face: a window / slot cut through the
174 // wall). Collected here and trimmed in a second pass, once every corner
175 // vertex has been solved, so both arcs meeting at a corner agree on it.
176 let mut open_rims: Vec<OpenRim> = Vec::new();
177 // Every RIM edge rebuilt below, closed circles and open arcs alike: the
178 // pushed face's pcurves for these are refitted when a multi-loop rebuild
179 // ran, because the replacement conic carries its own parameterization.
180 let mut rim_edges: Vec<u64> = Vec::new();
181
182 // PRE-PASS — a HOLE cut clean through the wall by ONE crossing carrier.
183 //
184 // A window bored by a crossing cylinder, a dome or a torus leaves an
185 // interior loop every one of whose edges is shared with the SAME neighbour
186 // face, and whose corner vertices have valence two: no third face meets
187 // there, so no triple point determines them and `resolve_open_rim_end` —
188 // which solves a triple point against a PLANE — has nothing to solve. The
189 // loop is one closed section that the arrangement stored as arcs, and the
190 // right rebuild is to re-intersect once and cut the section at the samples
191 // nearest the vertices it replaces. Handled here, ahead of the per-edge
192 // loop, because the decision is a property of the whole loop.
193 let mut hole_edges: HashSet<u64> = HashSet::default();
194 for loop_record in &pushed.loops {
195 let Some(hole) = single_neighbour_hole(
196 loop_record,
197 face_id,
198 solid,
199 &faces_of_edge,
200 &edge_by_id,
201 frame_origin,
202 frame_axis,
203 scale,
204 )?
205 else {
206 continue;
207 };
208 rebuild_single_neighbour_hole(
209 solid,
210 &s_prime,
211 &hole,
212 &edge_by_id,
213 &vertex_pos,
214 tolerance,
215 scale,
216 &mut new_curve,
217 &mut new_vertex,
218 )?;
219 for edge_id in &hole.edge_ids {
220 hole_edges.insert(*edge_id);
221 if !rim_edges.contains(edge_id) {
222 rim_edges.push(*edge_id);
223 }
224 }
225 curved_faces.insert(hole.neighbour);
226 }
227
228 for loop_record in &pushed.loops {
229 let coedge_count = loop_record.coedges.len();
230 for coedge_index in 0..coedge_count {
231 let coedge = &loop_record.coedges[coedge_index];
232 let edge = *edge_by_id
233 .get(&coedge.edge_id)
234 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
235 if hole_edges.contains(&edge.id) {
236 continue; // rebuilt whole, by the pre-pass above.
237 }
238 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
239 let is_seam = incident.iter().all(|f| *f == face_id);
240 if is_seam {
241 if !seam_edges.contains(&edge.id) {
242 seam_edges.push(edge.id);
243 }
244 continue;
245 }
246 // RIM: the fixed neighbour is the other face. Three lanes, in
247 // decreasing exactness and increasing generality:
248 //
249 // * a PLANE (an end cap) or a ruled revolution COAXIAL with the
250 // pushed carrier (a stepped/telescoping bore or boss) — the two
251 // original lanes, whose closed forms are reached by the exact
252 // call this function has always made, unchanged;
253 // * ANY OTHER carrier — sphere, torus, general revolution,
254 // non-coaxial cylinder/cone, free-form — through the shared
255 // re-intersection service (`offset/reintersect.rs`), which tries
256 // the same closed forms first and marches the pair when they
257 // decline. This is the lane the audit's §2.3 says push-face
258 // lacks and offset-shell has.
259 //
260 // The generic lane is admitted only where THIS rebuild can express
261 // the answer: a CLOSED rim, whose single vertex is a bookkeeping
262 // seam point rather than a triple point. An open arc against a
263 // curved neighbour needs `resolve_open_rim_end` to solve a triple
264 // point against a curved `N'`, which it cannot (it takes a plane),
265 // so that stays a refusal with its own reason.
266 let neighbour = *incident
267 .iter()
268 .find(|f| **f != face_id)
269 .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", edge.id))?;
270 let (nshell, nface) = find_face(solid, neighbour)
271 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
272 let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
273 let is_plane =
274 matches!(neighbour_surface.analytic(), Some(AnalyticSurface::Plane { .. }));
275 let is_coaxial_ruled =
276 ruled_neighbour_is_coaxial(neighbour_surface, frame_origin, frame_axis, scale);
277 let is_curved = !is_plane && !is_coaxial_ruled;
278 let separated = || {
279 "offset_ruled_face: the pushed carrier no longer meets a neighbour \
280 (the push separated them, or the rim left the neighbour's domain) — refusing"
281 .to_string()
282 };
283 // New rim = offset carrier ∩ neighbour carrier, the branch nearest
284 // the old edge.
285 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
286 let mut marched = false;
287 let curves = if is_curved {
288 if edge.start_vertex_id != edge.end_vertex_id {
289 return Err(format!(
290 "offset_ruled_face: the rim against curved neighbour {neighbour} is an \
291 OPEN arc (edge {}); an arc's corner is a triple point solved against a \
292 PLANE only — deferred (refusing)",
293 edge.id
294 ));
295 }
296 let policy = MarchPolicy {
297 tolerance,
298 // The rebuilt rim must sit on BOTH carriers tightly enough
299 // that its pcurves build and `validate` accepts them; the
300 // free-form push's own residual gate (0.05% of model scale)
301 // is the in-tree precedent for "how far an approximate
302 // offset result may be off".
303 residual_tolerance: (scale * 5e-4).max(5e-6),
304 // The boundary being replaced is the best seed set for the
305 // boundary replacing it.
306 seeds: edge_seeds(edge, 9)?,
307 };
308 match reintersect_carriers(&s_prime, neighbour_surface, &policy) {
309 Ok(found) => {
310 marched = found.lane == RimLane::Marched;
311 if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
312 eprintln!(
313 "RIM neighbour {neighbour}: lane {:?}, {} branch(es), \
314 residual {:.3e} (gate {:.3e})",
315 found.lane,
316 found.sections.len(),
317 found.residual,
318 policy.residual_tolerance
319 );
320 }
321 found.curves()
322 }
323 Err(ReintersectRefusal::Separated) => return Err(separated()),
324 Err(other) => {
325 return Err(format!("offset_ruled_face: {}", other.describe()));
326 }
327 }
328 } else {
329 // UNCHANGED: the exact call, with the same arguments in the same
330 // order, so every pair this function answered before is answered
331 // bit-identically now.
332 intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
333 .filter(|curves| !curves.is_empty())
334 .ok_or_else(separated)?
335 };
336 let rim = nearest_curve(&curves, old_mid)?;
337 if !rim_edges.contains(&edge.id) {
338 rim_edges.push(edge.id);
339 }
340 if edge.start_vertex_id == edge.end_vertex_id {
341 // A closed rim is a whole conic, so it must also run the way the
342 // edge it replaces ran — see `match_closed_rim_direction`.
343 let rim = if marched {
344 // A MARCHED section begins wherever the trace was seeded,
345 // not on the carrier's seam, so the start-tangent test would
346 // compare two unrelated places. Ask the same question at the
347 // old curve's nearest parameter instead.
348 match_marched_rim_direction(rim, edge)?
349 } else {
350 match_closed_rim_direction(rim, edge)?
351 };
352 // CLOSED rim (the canonical cap circle of a single-loop push):
353 // its seam-azimuth point is the relocated corner and matches the
354 // offset carrier's meridian. A recognized carrier's u = 0 IS its
355 // seam, and the shared rim's one seam vertex sits on both
356 // carriers' seams, so this placement keeps the coaxial
357 // neighbour's straight meridian on its carrier too. A marched
358 // section has no such convention, so its own domain start is
359 // where its single vertex goes — the vertex is a bookkeeping
360 // split of a closed curve, not a geometric corner.
361 let seam_point = if marched {
362 let [d0, _] = rim.domain()?;
363 rim.evaluate(d0)?
364 } else {
365 rim.evaluate(0.0)?
366 };
367 new_vertex.insert(edge.start_vertex_id, seam_point);
368 new_vertex.insert(edge.end_vertex_id, seam_point);
369 new_curve.insert(edge.id, rim);
370 } else {
371 // OPEN rim: an arc / generatrix bounding a window cut through
372 // the wall. `rim` is the WHOLE conic the two carriers share, so
373 // each endpoint must be re-solved (it is the triple point
374 // `S' ∩ N ∩ N'` against the ADJACENT rim's neighbour, or the
375 // pushed carrier's own seam) and the conic trimmed between them.
376 // Collapsing both onto `rim.evaluate(0.0)` — the closed-rim rule
377 // — is what used to tear a multi-loop push apart.
378 let previous =
379 &loop_record.coedges[(coedge_index + coedge_count - 1) % coedge_count];
380 let next = &loop_record.coedges[(coedge_index + 1) % coedge_count];
381 let (at_start, at_end) = if coedge.forward {
382 (previous, next)
383 } else {
384 (next, previous)
385 };
386 let mut resolve = |adjacent_coedge: &CoedgeRecord,
387 vertex_id: u64|
388 -> Result<RimEnd, String> {
389 let adjacent = *edge_by_id.get(&adjacent_coedge.edge_id).ok_or_else(|| {
390 format!(
391 "offset_ruled_face: missing edge {}",
392 adjacent_coedge.edge_id
393 )
394 })?;
395 let old_point = vertex_pos
396 .get(&vertex_id)
397 .copied()
398 .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
399 let (end, point) = resolve_open_rim_end(
400 solid,
401 face_id,
402 &faces_of_edge,
403 &rim,
404 adjacent,
405 old_point,
406 plane_tolerance,
407 )?;
408 // Both arcs meeting at a corner solve the SAME triple point
409 // from their own conic; a disagreement means the branch pick
410 // went to different roots, so refuse rather than tear.
411 match new_vertex.get(&vertex_id).copied() {
412 Some(existing) if existing.sub(point).length() > plane_tolerance => {
413 return Err(
414 "offset_ruled_face: the two rims meeting at a multi-loop corner \
415 disagree on its new position — refusing"
416 .into(),
417 )
418 }
419 Some(_) => {}
420 None => {
421 new_vertex.insert(vertex_id, point);
422 }
423 }
424 Ok(end)
425 };
426 let start = resolve(at_start, edge.start_vertex_id)?;
427 let end = resolve(at_end, edge.end_vertex_id)?;
428 open_rims.push(OpenRim {
429 edge_id: edge.id,
430 conic: rim,
431 start,
432 end,
433 old_mid,
434 start_vertex_id: edge.start_vertex_id,
435 end_vertex_id: edge.end_vertex_id,
436 });
437 }
438 if is_plane {
439 cap_faces.insert(neighbour);
440 } else if is_coaxial_ruled {
441 ruled_faces.insert(neighbour);
442 } else {
443 curved_faces.insert(neighbour);
444 }
445 }
446 }
447
448 // Second pass: trim each open rim's conic to the arc BETWEEN its two solved
449 // corners — the one that contains the old edge, picked by the old midpoint's
450 // parameter — and orient it start-vertex → end-vertex.
451 for rim in &open_rims {
452 let [d0, d1] = rim.conic.domain()?;
453 let middle = project_point_to_curve(&rim.conic, rim.old_mid)?.u;
454 let (from, to) = match (rim.start, rim.end) {
455 (RimEnd::Corner(a), RimEnd::Corner(b)) => {
456 let (low, high) = if a <= b { (a, b) } else { (b, a) };
457 if middle < low || middle > high {
458 return Err(
459 "offset_ruled_face: a multi-loop rim arc wraps the pushed carrier's \
460 periodic seam — deferred (refusing)"
461 .into(),
462 );
463 }
464 (low, high)
465 }
466 (RimEnd::Seam, RimEnd::Corner(corner)) | (RimEnd::Corner(corner), RimEnd::Seam) => {
467 if middle < corner {
468 (d0, corner)
469 } else {
470 (corner, d1)
471 }
472 }
473 (RimEnd::Seam, RimEnd::Seam) => {
474 return Err(
475 "offset_ruled_face: a multi-loop rim arc ends on the seam at BOTH ends — \
476 refusing"
477 .into(),
478 )
479 }
480 };
481 let mut trimmed = subcurve(&rim.conic, from, to)?;
482 let start_point = *new_vertex.get(&rim.start_vertex_id).ok_or_else(|| {
483 "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
484 })?;
485 let end_point = *new_vertex.get(&rim.end_vertex_id).ok_or_else(|| {
486 "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
487 })?;
488 let [t0, _] = trimmed.domain()?;
489 let head = trimmed.evaluate(t0)?;
490 if head.sub(start_point).length() > head.sub(end_point).length() {
491 trimmed = trimmed.reversed()?;
492 }
493 new_curve.insert(rim.edge_id, trimmed);
494 }
495
496 let pos = |vid: u64| -> Vec3 {
497 new_vertex
498 .get(&vid)
499 .copied()
500 .unwrap_or_else(|| vertex_pos[&vid])
501 };
502
503 // Seam edge(s): the straight generatrix segment of the offset carrier, from
504 // the relocated bottom seam vertex to the top one. Both endpoints were placed
505 // on S′'s seam meridian by the rim re-intersections above, so a line between
506 // them rides u = 0 of S′ over the SAME axial range the seam had — using the
507 // full `iso_curve_u` meridian would wrongly span the untrimmed carrier (a
508 // boolean-drilled wall's surface runs past the plate faces).
509 for seam_id in &seam_edges {
510 let seam = *edge_by_id
511 .get(seam_id)
512 .ok_or_else(|| format!("offset_ruled_face: missing seam edge {seam_id}"))?;
513 let start = *new_vertex.get(&seam.start_vertex_id).ok_or_else(|| {
514 "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
515 })?;
516 let end = *new_vertex.get(&seam.end_vertex_id).ok_or_else(|| {
517 "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
518 })?;
519 new_curve.insert(*seam_id, make_line(start, end)?);
520 }
521
522 // A COAXIAL ruled neighbour also has its own straight seam meridian ending
523 // at the shared rim's (now moved) seam vertex. Rebuild it as the chord
524 // between its endpoints — one relocated by the rim, the other its unmoved
525 // rim vertex — which, both lying on the neighbour's straight generatrix, is
526 // exactly the meridian segment.
527 for ruled in &ruled_faces {
528 let (nshell, nface) = find_face(solid, *ruled)
529 .ok_or_else(|| format!("offset_ruled_face: missing ruled neighbour {ruled}"))?;
530 for loop_record in &solid.shells[nshell].faces[nface].loops {
531 for coedge in &loop_record.coedges {
532 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
533 format!("offset_ruled_face: missing edge {}", coedge.edge_id)
534 })?;
535 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
536 let is_seam = incident.iter().all(|f| *f == *ruled);
537 let touches_moved = new_vertex.contains_key(&edge.start_vertex_id)
538 || new_vertex.contains_key(&edge.end_vertex_id);
539 if is_seam && touches_moved && !new_curve.contains_key(&edge.id) {
540 new_curve.insert(
541 edge.id,
542 make_line(pos(edge.start_vertex_id), pos(edge.end_vertex_id))?,
543 );
544 }
545 }
546 }
547 }
548
549 // A CURVED neighbour keeps its surface, so it keeps every 3D CURVE it owns
550 // — a sphere's seam meridian is the same great-circle arc after the push as
551 // before it. What changes is where that curve is TRIMMED: the endpoint the
552 // moved rim carried with it slides along the curve to a new parameter.
553 // Re-solving it as a parameter on the existing curve is EXACT, and it is
554 // strictly better than rebuilding: the coaxial-ruled lane above can chord
555 // its seam only because a ruled revolution's meridian is straight, and a
556 // sphere's is not.
557 let mut new_range: HashMap<u64, (f64, f64)> = HashMap::default();
558 for curved in &curved_faces {
559 let (nshell, nface) = find_face(solid, *curved)
560 .ok_or_else(|| format!("offset_ruled_face: missing curved neighbour {curved}"))?;
561 for loop_record in &solid.shells[nshell].faces[nface].loops {
562 for coedge in &loop_record.coedges {
563 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
564 format!("offset_ruled_face: missing edge {}", coedge.edge_id)
565 })?;
566 if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
567 continue;
568 }
569 let start_moved = new_vertex.get(&edge.start_vertex_id).copied();
570 let end_moved = new_vertex.get(&edge.end_vertex_id).copied();
571 if start_moved.is_none() && end_moved.is_none() {
572 continue;
573 }
574 if edge.start_vertex_id == edge.end_vertex_id {
575 // A closed or degenerate edge of the neighbour (its own
576 // opposite rim, or a pole) whose vertex a rim relocated:
577 // the whole curve would have to move, which this lane does
578 // not do.
579 return Err(format!(
580 "offset_ruled_face: the push moved the vertex of curved neighbour \
581 {curved}'s CLOSED edge {} — deferred (refusing)",
582 edge.id
583 ));
584 }
585 let mut range = (edge.t0, edge.t1);
586 for (moved, slot) in [(start_moved, 0usize), (end_moved, 1usize)] {
587 let Some(point) = moved else { continue };
588 let projection = project_point_to_curve(&edge.curve, point)?;
589 if projection.distance > plane_tolerance {
590 return Err(format!(
591 "offset_ruled_face: a relocated rim vertex left curved neighbour \
592 {curved}'s edge {} (off by {:.3e}) — refusing",
593 edge.id, projection.distance
594 ));
595 }
596 if slot == 0 {
597 range.0 = projection.u;
598 } else {
599 range.1 = projection.u;
600 }
601 }
602 new_range.insert(edge.id, range);
603 }
604 }
605 }
606 // Fail-safe: every relocated vertex must belong only to edges this push
607 // rebuilt or re-trimmed. A vertex that also ends an edge of some other face
608 // would leave that face's boundary behind, which is exactly the silent
609 // tear the ruled lane's own corner guard refuses.
610 if !curved_faces.is_empty() {
611 for edge in &solid.edges {
612 if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
613 continue;
614 }
615 if new_vertex.contains_key(&edge.start_vertex_id)
616 || new_vertex.contains_key(&edge.end_vertex_id)
617 {
618 return Err(format!(
619 "offset_ruled_face: relocating a curved neighbour's rim moved the end of \
620 edge {}, which this push does not rebuild — refusing",
621 edge.id
622 ));
623 }
624 }
625 }
626
627 // A multi-loop window's corner is a TRIPLE point, so it is also the end of a
628 // third edge that belongs to neither the pushed face nor a rim: the two
629 // fixed neighbour planes' own shared edge (a slot floor meeting a slot
630 // side wall). Both carriers are fixed, so that edge stays on their
631 // intersection line and the chord between its (possibly relocated)
632 // endpoints IS the rebuilt edge. Skipped entirely when no open rim was
633 // rebuilt, so the single-loop paths are untouched.
634 if !open_rims.is_empty() {
635 let mut corner_edges: Vec<(u64, NurbsCurve)> = Vec::new();
636 for edge in &solid.edges {
637 if new_curve.contains_key(&edge.id) {
638 continue;
639 }
640 if !new_vertex.contains_key(&edge.start_vertex_id)
641 && !new_vertex.contains_key(&edge.end_vertex_id)
642 {
643 continue;
644 }
645 if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
646 return Err(
647 "offset_ruled_face: the push moved the end of a CURVED edge that is not a \
648 rebuilt rim — refusing"
649 .into(),
650 );
651 }
652 let start = pos(edge.start_vertex_id);
653 let end = pos(edge.end_vertex_id);
654 for neighbour in faces_of_edge.get(&edge.id).cloned().unwrap_or_default() {
655 if !cap_faces.contains(&neighbour) {
656 return Err(
657 "offset_ruled_face: a relocated corner borders a face this push does not \
658 re-trim — refusing"
659 .into(),
660 );
661 }
662 let (nshell, nface) = find_face(solid, neighbour).ok_or_else(|| {
663 format!("offset_ruled_face: missing neighbour {neighbour}")
664 })?;
665 let plane = plane_of_surface(
666 &solid.shells[nshell].faces[nface].surface,
667 plane_tolerance,
668 "offset_ruled_face",
669 )?;
670 for point in [start, end] {
671 if point.sub(plane.origin).dot(plane.normal).abs() > plane_tolerance {
672 return Err(
673 "offset_ruled_face: a relocated corner left one of its fixed \
674 neighbour planes — refusing"
675 .into(),
676 );
677 }
678 }
679 }
680 corner_edges.push((edge.id, make_line(start, end)?));
681 }
682 for (edge_id, curve) in corner_edges {
683 new_curve.insert(edge_id, curve);
684 }
685 }
686
687 // Every edge this push touched, by curve or by trim range — the selective
688 // re-fit's work list.
689 let changed_edges: HashSet<u64> = new_curve
690 .keys()
691 .chain(new_range.keys())
692 .copied()
693 .collect();
694
695 // --- Apply to a fresh clone (the input is never mutated) ---------------
696 let mut result = solid.clone();
697 for edge in &mut result.edges {
698 if let Some(curve) = new_curve.get(&edge.id) {
699 // A trimmed rim arc carries its PARENT conic's parameter range
700 // (`NurbsCurve::split` preserves knot values), so the edge range
701 // comes from the curve. Every whole-curve rebuild — the closed rims,
702 // the seam lines — still lands on [0, 1] exactly as before.
703 let [d0, d1] = curve.domain()?;
704 edge.curve = curve.clone();
705 edge.t0 = d0;
706 edge.t1 = d1;
707 } else if let Some((t0, t1)) = new_range.get(&edge.id) {
708 // A curved neighbour's own edge: same curve, new trim.
709 edge.t0 = *t0;
710 edge.t1 = *t1;
711 }
712 }
713 for vertex in &mut result.vertices {
714 if let Some(point) = new_vertex.get(&vertex.id) {
715 vertex.point = *point;
716 }
717 }
718 // The pushed face rides the offset carrier. S′ shares the source's parameter
719 // domain + seam azimuth (same make_revolution frame/span), so the face's
720 // existing (u, v) pcurves stay valid when every rim stayed at its axial
721 // station (the planar-cap push) — only the surface swaps.
722 result.shells[pshell].faces[pface].surface = s_prime;
723
724 let final_edges: HashMap<u64, EdgeRecord> =
725 result.edges.iter().map(|e| (e.id, e.clone())).collect();
726
727 // A COAXIAL ruled neighbour moves the shared rim ALONG the axis, so the
728 // pushed face's pcurve for that rim (and the seam whose endpoint moved) is
729 // no longer at its old v — refit both the pushed face and every ruled
730 // neighbour on their (axially-grown-if-needed) carriers. The planar-only
731 // push skips this and keeps the exact pcurve reuse above.
732 //
733 // A CURVED neighbour moves the shared rim just as much (a dome's rim climbs
734 // the sphere as the rod it caps grows), so it joins the same pass — but its
735 // own carrier neither moves nor grows: a sphere, a torus and a full
736 // revolution are already closed over their whole surface, and the rim was
737 // intersected WITH that surface, so it is on it by construction. Its retrim
738 // is therefore the same driver with a growth that does nothing.
739 if !ruled_faces.is_empty() {
740 retrim_offset_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
741 for ruled in &ruled_faces {
742 retrim_offset_ruled_face(&mut result, *ruled, &final_edges, tolerance)?;
743 }
744 for curved in &curved_faces {
745 refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
746 }
747 } else if !curved_faces.is_empty() {
748 // The pushed carrier still has to GROW where the rim climbed past its
749 // axial span — the same exact prolongation the ruled lane uses — but its
750 // pcurves are re-fitted selectively, not wholesale.
751 let (gshell, gface) = find_face(&result, face_id)
752 .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
753 let samples = boundary_samples(
754 &result.shells[gshell].faces[gface],
755 &final_edges,
756 "offset_ruled_face",
757 )?;
758 extend_ruled_neighbour_over(&mut result, face_id, &samples, tolerance)?;
759 refit_changed_pcurves(&mut result, face_id, &final_edges, &changed_edges, tolerance)?;
760 for curved in &curved_faces {
761 refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
762 }
763 } else if !rim_edges.is_empty() {
764 // Every rebuilt RIM needs a fresh pcurve on S'. An open rim because its
765 // azimuth span (an arc) or station (a generatrix) genuinely moved; a
766 // CLOSED rim because the replacement conic carries the INTERSECTOR's
767 // parameterization, which need not be the one the incoming curve had —
768 // a boolean-cut cylinder's cap circle comes back re-parameterized, and
769 // a closed rim under an OBLIQUE planar cap is an ellipse whose height
770 // profile v(u) changes with the offset (the cap plane climbs as the
771 // carrier grows: measured 0.382 against a 0.206 limit when the old
772 // pcurve was kept on the oblique-cut cone of PushFaceTest2). So this
773 // pass runs for every rebuilt rim, not only when an open rim exists;
774 // for a perpendicular cap circle it rebuilds the same v = const line.
775 // `validate` pairs pcurve to curve by FRACTION of their domains, so a
776 // pointwise-identical circle with a different parameter distribution
777 // still reads as a gross deviation (measured 5.96 and 12.0 on the slot
778 // fixture, against a 0.394 limit).
779 //
780 // The SEAM edges are deliberately left alone: each is rebuilt as a
781 // straight chord over the same axial range, so fraction ↦ height is
782 // unchanged and their exact (u, v) — including the two coedges sitting
783 // on OPPOSITE sides of the periodic seam — survives untouched.
784 let rebuilt: HashSet<u64> = rim_edges.iter().copied().collect();
785 let surface = result.shells[pshell].faces[pface].surface.clone();
786 let [u_start, u_end] = surface.domain_u()?;
787 let u_period = u_end - u_start;
788 for loop_record in &mut result.shells[pshell].faces[pface].loops {
789 for coedge in &mut loop_record.coedges {
790 if !rebuilt.contains(&coedge.edge_id) {
791 continue;
792 }
793 let edge = final_edges
794 .get(&coedge.edge_id)
795 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
796 let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
797 if !coedge.forward {
798 pcurve = pcurve.reversed()?;
799 }
800 coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
801 }
802 }
803 }
804
805 // The fixed caps re-trim as planar faces around their grown rim circles.
806 for cap in &cap_faces {
807 let (cshell, cface) = find_face(&result, *cap)
808 .ok_or_else(|| format!("offset_ruled_face: missing cap {cap}"))?;
809 let plane = plane_of_surface(
810 &result.shells[cshell].faces[cface].surface,
811 plane_tolerance,
812 "offset_ruled_face",
813 )?;
814 retrim_planar_face(
815 &mut result.shells[cshell].faces[cface],
816 &plane,
817 &final_edges,
818 scale,
819 "offset_ruled_face",
820 )?;
821 }
822
823 let issues = result.validate();
824 if !issues.is_empty() {
825 if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
826 // Which coedge of the pushed face drifted, and by how much, with
827 // the pcurve's endpoints so a re-anchoring or direction mistake is
828 // visible next to a fitting one.
829 let (pshell, pface) = find_face(&result, face_id).expect("pushed face survives");
830 let face = &result.shells[pshell].faces[pface];
831 for (li, loop_record) in face.loops.iter().enumerate() {
832 for coedge in &loop_record.coedges {
833 let Some(edge) = result.edges.iter().find(|e| e.id == coedge.edge_id) else {
834 continue;
835 };
836 let (Ok([p0, p1]), Ok(c0)) = (coedge.pcurve.domain(), edge.curve.evaluate(edge.t0))
837 else {
838 continue;
839 };
840 let mut worst = 0.0f64;
841 for k in 0..=32 {
842 let f = k as f64 / 32.0;
843 let t = if coedge.forward {
844 edge.t0 + (edge.t1 - edge.t0) * f
845 } else {
846 edge.t1 - (edge.t1 - edge.t0) * f
847 };
848 if let (Ok(q), Ok(target)) =
849 (coedge.pcurve.evaluate(p0 + (p1 - p0) * f), edge.curve.evaluate(t))
850 {
851 if let Ok(on_s) = face.surface.evaluate(q.x, q.y) {
852 worst = worst.max(on_s.sub(target).length());
853 }
854 }
855 }
856 let (Ok(a), Ok(b)) = (coedge.pcurve.evaluate(p0), coedge.pcurve.evaluate(p1)) else {
857 continue;
858 };
859 eprintln!(
860 "PUSH-DEBUG face {} loop {li} edge {} fwd={} rebuilt={} t=[{:.4},{:.4}] dom={:?} \
861 curve(t0)=({:.4},{:.4},{:.4}) pcurve ({:.4},{:.4})->({:.4},{:.4}) dev={worst:.4}",
862 face.id,
863 edge.id,
864 coedge.forward,
865 rim_edges.contains(&edge.id),
866 edge.t0,
867 edge.t1,
868 edge.curve.domain().ok(),
869 c0.x,
870 c0.y,
871 c0.z,
872 a.x,
873 a.y,
874 b.x,
875 b.y
876 );
877 }
878 }
879 }
880 return Err(format!(
881 "offset_ruled_face: pushed solid failed validation: {issues:?}"
882 ));
883 }
884 if let (Ok(before), Ok(after)) =
885 (solid_signed_volume(solid), solid_signed_volume(&result))
886 {
887 if before * after <= 0.0 {
888 return Err("offset_ruled_face: the push inverts the solid — refusing".into());
889 }
890 }
891 Ok(result)
892}
893
894/// Where an OPEN rim arc's endpoint sits on the rebuilt conic.
895#[derive(Clone, Copy)]
896enum RimEnd {
897 /// A genuine corner: the TRIPLE point `offset carrier ∩ this rim's
898 /// neighbour ∩ the ADJACENT rim's neighbour`, carried as the conic
899 /// parameter that lands on it.
900 Corner(f64),
901 /// The pushed carrier's periodic SEAM split this rim in two, so the
902 /// endpoint rides the offset carrier's seam meridian — which is exactly
903 /// parameter 0 (== 1) of the conic, because `S'` is revolved about the
904 /// SAME frame and seam azimuth as the source carrier.
905 Seam,
906}
907
908/// One open rim arc, held between the two passes of the rebuild.
909struct OpenRim {
910 edge_id: u64,
911 /// The WHOLE conic `S' ∩ N`, before trimming.
912 conic: NurbsCurve,
913 start: RimEnd,
914 end: RimEnd,
915 /// The OLD edge's midpoint: picks which of the two complementary arcs
916 /// between the corners is the one this edge actually was.
917 old_mid: Vec3,
918 start_vertex_id: u64,
919 end_vertex_id: u64,
920}
921
922/// Resolve ONE endpoint of an open rim arc against the edge that follows it
923/// around the loop.
924///
925/// Two cases, and nothing else is admitted:
926/// * the adjacent edge is the pushed face's own SEAM — the endpoint rides the
927/// offset carrier's seam meridian;
928/// * the adjacent edge's fixed neighbour `N'` is a PLANE — the endpoint is the
929/// triple point `S' ∩ N ∩ N'`, i.e. where this rim's conic (already the
930/// `S' ∩ N` curve) crosses `N'`. The crossing nearest the old vertex is the
931/// branch, so a conic that meets `N'` twice picks the right corner.
932///
933/// A conic that LIES IN `N'` means the boolean merely split one rim in two
934/// (`N == N'`); there is no triple point, and the split simply keeps its
935/// azimuth on the rebuilt conic. Any other adjacent neighbour (a curved
936/// carrier) makes `plane_of_surface` refuse, which is the fail-safe.
937fn resolve_open_rim_end(
938 solid: &BrepSolid,
939 face_id: u64,
940 faces_of_edge: &HashMap<u64, Vec<u64>>,
941 conic: &NurbsCurve,
942 adjacent: &EdgeRecord,
943 old_point: Vec3,
944 plane_tolerance: f64,
945) -> Result<(RimEnd, Vec3), String> {
946 let incident = faces_of_edge.get(&adjacent.id).cloned().unwrap_or_default();
947 if incident.iter().all(|f| *f == face_id) {
948 let [d0, _] = conic.domain()?;
949 return Ok((RimEnd::Seam, conic.evaluate(d0)?));
950 }
951 let other = *incident
952 .iter()
953 .find(|f| **f != face_id)
954 .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", adjacent.id))?;
955 let (nshell, nface) = find_face(solid, other)
956 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {other}"))?;
957 let plane = plane_of_surface(
958 &solid.shells[nshell].faces[nface].surface,
959 plane_tolerance,
960 "offset_ruled_face",
961 )?;
962 if curve_lies_in_plane(conic, &plane, plane_tolerance)? {
963 let projection = project_point_to_curve(conic, old_point)?;
964 return Ok((RimEnd::Corner(projection.u), conic.evaluate(projection.u)?));
965 }
966 let mut best: Option<(f64, f64)> = None;
967 for parameter in plane_crossing_params(conic, &plane)? {
968 let distance = conic.evaluate(parameter)?.sub(old_point).length();
969 if best.map(|(best, _)| distance < best).unwrap_or(true) {
970 best = Some((distance, parameter));
971 }
972 }
973 let (_, parameter) = best.ok_or_else(|| {
974 "offset_ruled_face: a multi-loop rim corner no longer meets its adjacent neighbour \
975 (the push pulled the window off it) — refusing"
976 .to_string()
977 })?;
978 Ok((RimEnd::Corner(parameter), conic.evaluate(parameter)?))
979}
980
981/// TRUE when the whole curve lies in the plane (so it cannot cross it): the
982/// adjacent rim shares this rim's carrier plane and the shared vertex is a
983/// SPLIT point, not a triple point.
984fn curve_lies_in_plane(
985 curve: &NurbsCurve,
986 plane: &Plane,
987 plane_tolerance: f64,
988) -> Result<bool, String> {
989 let [d0, d1] = curve.domain()?;
990 for step in 0..=16 {
991 let t = d0 + (d1 - d0) * step as f64 / 16.0;
992 if curve
993 .evaluate(t)?
994 .sub(plane.origin)
995 .dot(plane.normal)
996 .abs()
997 > plane_tolerance
998 {
999 return Ok(false);
1000 }
1001 }
1002 Ok(true)
1003}
1004
1005/// Every parameter at which `curve` crosses `plane`, by dense sampling of the
1006/// signed plane distance plus bisection on each sign change. The curves here
1007/// are conics (a circle crosses a slot wall twice, a generatrix line crosses a
1008/// slot floor once), so a sampled sign-change sweep finds every root; bisection
1009/// then drives it to the last bit rather than to a fit tolerance.
1010fn plane_crossing_params(curve: &NurbsCurve, plane: &Plane) -> Result<Vec<f64>, String> {
1011 let [d0, d1] = curve.domain()?;
1012 let signed = |t: f64| -> Result<f64, String> {
1013 Ok(curve.evaluate(t)?.sub(plane.origin).dot(plane.normal))
1014 };
1015 const SAMPLES: usize = 512;
1016 let mut roots = Vec::new();
1017 let mut previous = (d0, signed(d0)?);
1018 for index in 1..=SAMPLES {
1019 let t = d0 + (d1 - d0) * index as f64 / SAMPLES as f64;
1020 let value = signed(t)?;
1021 if previous.1 == 0.0 {
1022 roots.push(previous.0);
1023 } else if (previous.1 < 0.0) != (value < 0.0) {
1024 let (mut low, mut high) = (previous.0, t);
1025 let mut low_value = previous.1;
1026 for _ in 0..100 {
1027 let middle = 0.5 * (low + high);
1028 if middle <= low || middle >= high {
1029 break;
1030 }
1031 let middle_value = signed(middle)?;
1032 if (low_value < 0.0) != (middle_value < 0.0) {
1033 high = middle;
1034 } else {
1035 low = middle;
1036 low_value = middle_value;
1037 }
1038 }
1039 roots.push(0.5 * (low + high));
1040 }
1041 previous = (t, value);
1042 }
1043 if previous.1 == 0.0 {
1044 roots.push(previous.0);
1045 }
1046 Ok(roots)
1047}
1048
1049/// The piece of `curve` over `[from, to]`. `NurbsCurve::split` keeps the parent
1050/// parameterization, so the result's own domain IS `[from, to]` — which is what
1051/// the edge's `t0`/`t1` are then set from.
1052fn subcurve(curve: &NurbsCurve, from: f64, to: f64) -> Result<NurbsCurve, String> {
1053 let [d0, d1] = curve.domain()?;
1054 let span = (d1 - d0).max(1e-12);
1055 if to - from <= 1e-9 * span {
1056 return Err("offset_ruled_face: a rebuilt rim arc collapsed to a point — refusing".into());
1057 }
1058 let mut trimmed = curve.clone();
1059 if to < d1 - 1e-9 * span {
1060 trimmed = trimmed.split(to)?.0;
1061 }
1062 if from > d0 + 1e-9 * span {
1063 trimmed = trimmed.split(from)?.1;
1064 }
1065 Ok(trimmed)
1066}
1067
1068/// Orient a rebuilt CLOSED rim the way the edge it replaces ran.
1069///
1070/// `intersect_analytic_pair` always emits its conics in its OWN direction
1071/// (increasing azimuth about the carrier frame). The edge being replaced need
1072/// not run that way: a wall's top and bottom cap circles are traversed in
1073/// OPPOSITE directions around the face loop, so one of them arrives decreasing.
1074/// Both are the same point set and a closed rim's two endpoints are the same
1075/// vertex, so `validate` cannot tell them apart — but the face's pcurve for the
1076/// reversed one runs `u: 1 → 0`, and handing the loop an increasing rim tears it
1077/// open in parameter space, which the Green's-theorem volume integral reads as a
1078/// completely different solid (measured 138.18 against a true 1024.11).
1079///
1080/// Decided on the START TANGENT, which is local and exact: a closed rim starts
1081/// on the carrier's seam and so does the rebuilt conic, so the two tangents
1082/// there either agree or oppose. OPEN rims are not orientated here — they are
1083/// trimmed to their solved corners first and then turned to run
1084/// start-vertex → end-vertex.
1085fn match_closed_rim_direction(
1086 rim: NurbsCurve,
1087 previous: &EdgeRecord,
1088) -> Result<NurbsCurve, String> {
1089 let [d0, _] = rim.domain()?;
1090 let incoming = previous.curve.derivatives(previous.t0, 1)?;
1091 let rebuilt = rim.derivatives(d0, 1)?;
1092 if incoming[1].dot(rebuilt[1]) < 0.0 {
1093 return rim.reversed();
1094 }
1095 Ok(rim)
1096}
1097
1098/// Put a freshly fitted pcurve back on the periodic BRANCH of `u` that the
1099/// pcurve it replaces used, by the whole-period shift that aligns their starts.
1100///
1101/// This is not cosmetic. `build_pcurve_on_surface` CLAMPS an analytic carrier's
1102/// parameters into `[u0, u1]`, but a face loop on a full revolution legitimately
1103/// carries `u` one period PAST the domain: the two coedges of the periodic seam
1104/// sit on opposite sides of it, so the coedge that closes the loop across the
1105/// seam runs (say) `u: 1 → 2`. `validate` accepts either branch — it evaluates
1106/// the surface with `evaluate_extended`, which wraps — but the Green's-theorem
1107/// area/volume integral does NOT: it integrates the wire as drawn in parameter
1108/// space, and a rim dropped back to `u: 0 → 1` tears the loop open and yields a
1109/// nonsense volume (measured 138.18 for a solid whose true volume is 1024.11).
1110///
1111/// The replacement rim traverses the same path in the same direction as the edge
1112/// it replaces — only its parameter DISTRIBUTION and (for an open arc) its
1113/// endpoints change — so aligning the starts fixes the branch, and the endpoint
1114/// check refuses anything that is not merely re-anchored.
1115fn reanchor_pcurve_u(
1116 pcurve: &NurbsCurve,
1117 previous: &NurbsCurve,
1118 u_period: f64,
1119) -> Result<NurbsCurve, String> {
1120 if !(u_period.is_finite() && u_period > 0.0) {
1121 return Ok(pcurve.clone());
1122 }
1123 let [a0, a1] = pcurve.domain()?;
1124 let [b0, b1] = previous.domain()?;
1125 let shift = ((previous.evaluate(b0)?.x - pcurve.evaluate(a0)?.x) / u_period).round() * u_period;
1126 let shifted = if shift == 0.0 {
1127 pcurve.clone()
1128 } else {
1129 let controls = pcurve
1130 .control_points
1131 .iter()
1132 .map(|control| {
1133 let mut point = control.point()?;
1134 point.x += shift;
1135 Ok(crate::Vec4::from_point(point, control.w))
1136 })
1137 .collect::<Result<Vec<_>, String>>()?;
1138 NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), controls)?
1139 };
1140 // Both ends must land within a quarter period of the ones they replace: a
1141 // rim that reversed direction, or that jumped a branch mid-curve, is not a
1142 // re-anchoring and must not be silently accepted.
1143 let end_drift = (shifted.evaluate(a1)?.x - previous.evaluate(b1)?.x).abs();
1144 if end_drift > 0.25 * u_period {
1145 return Err(format!(
1146 "offset_ruled_face: a rebuilt rim traverses the carrier's periodic parameter \
1147 differently from the edge it replaces (end drift {end_drift}) — refusing"
1148 ));
1149 }
1150 Ok(shifted)
1151}
1152
1153/// TRUE when `neighbour` is a ruled revolution (cylinder or cone) sharing the
1154/// pushed carrier's axis LINE — same axis direction (up to sign) and the axes
1155/// coincident. Only such a neighbour has a closed-form re-intersection with the
1156/// offset carrier (`intersect_coaxial_revolutions`); the tolerances mirror that
1157/// intersector so an accepted neighbour is one it can actually solve.
1158pub(super) fn ruled_neighbour_is_coaxial(
1159 neighbour: &NurbsSurface,
1160 axis_origin: Vec3,
1161 axis_dir: Vec3,
1162 scale: f64,
1163) -> bool {
1164 let Some(AnalyticSurface::RuledRevolution { frame, .. }) = neighbour.analytic() else {
1165 return false;
1166 };
1167 if frame.axis.dot(axis_dir).abs() < 1.0 - 1e-9 {
1168 return false;
1169 }
1170 let offset = frame.origin.sub(axis_origin);
1171 let perpendicular = offset.sub(axis_dir.scale(offset.dot(axis_dir)));
1172 perpendicular.length() <= 1e-9 * scale.max(1.0)
1173}
1174
1175/// Re-trim a ruled-revolution face (the pushed carrier itself, now S′, or a
1176/// coaxial ruled neighbour) whose boundary moved axially: grow the carrier along
1177/// its axis to cover the updated boundary (`extend_ruled_neighbour_over`, exact),
1178/// then rebuild every pcurve from the already-updated edge curves. The offset
1179/// analogue of `retrim_planar_face` — all loops are visited, so holes carry.
1180///
1181/// The three-phase body is `crate::offset_retrim::retrim_face_in_solid`; this is
1182/// the ruled growth strategy and the `offset_ruled_face` refusal prefix.
1183pub(super) fn retrim_offset_ruled_face(
1184 result: &mut BrepSolid,
1185 face_id: u64,
1186 final_edges: &HashMap<u64, EdgeRecord>,
1187 tolerance: f64,
1188) -> Result<(), String> {
1189 let (shell, face_pos) = find_face(result, face_id)
1190 .ok_or_else(|| format!("offset_ruled_face: missing ruled face {face_id}"))?;
1191 retrim_face_in_solid(
1192 result,
1193 shell,
1194 face_pos,
1195 final_edges,
1196 |solid, points| extend_ruled_neighbour_over(solid, face_id, points, tolerance),
1197 PcurveFit::SubrangeAware { tolerance },
1198 "offset_ruled_face",
1199 )
1200}
1201
1202/// A window through the wall bounded entirely by ONE crossing carrier.
1203struct SingleNeighbourHole {
1204 neighbour: u64,
1205 /// Every edge of the loop, in loop order, de-duplicated.
1206 edge_ids: Vec<u64>,
1207 /// Corner vertices that are ALSO the end of one of the neighbour's own
1208 /// edges — its seam meridian, in every case the corpus reaches. Such a
1209 /// corner is NOT a free bookkeeping split: it is pinned to that meridian,
1210 /// so it moves along it rather than to the nearest point of the new
1211 /// section. `(vertex id, the neighbour edge it is pinned to)`.
1212 pinned: Vec<(u64, u64)>,
1213}
1214
1215/// The axial half-plane a revolution carrier's SEAM meridian lies in.
1216///
1217/// Every seam of every revolution carrier this lane admits — a cylinder, a
1218/// cone, a sphere, a torus, a general revolution — is a meridian, and a
1219/// meridian lies in the half-plane spanned by the axis and its own radial
1220/// direction. So "where does the new section cross the neighbour's seam" is a
1221/// curve × plane crossing, in closed form, for all five at once. Returns `None`
1222/// for a carrier that is not a revolution or a seam that runs ON the axis.
1223fn seam_axial_plane(surface: &NurbsSurface, seam: &EdgeRecord) -> Option<Plane> {
1224 let structure = crate::revolution_structure(surface)?;
1225 let midpoint = seam
1226 .curve
1227 .evaluate(0.5 * (seam.t0 + seam.t1))
1228 .ok()?;
1229 let offset = midpoint.sub(structure.frame.origin);
1230 let radial = offset
1231 .sub(structure.frame.axis.scale(offset.dot(structure.frame.axis)))
1232 .normalized()
1233 .ok()?;
1234 let normal = structure.frame.axis.cross(radial).normalized().ok()?;
1235 Some(Plane {
1236 origin: structure.frame.origin,
1237 u_dir: structure.frame.axis,
1238 v_dir: radial,
1239 normal,
1240 })
1241}
1242
1243/// Recognise a loop that is a hole cut by ONE crossing carrier, or say why not.
1244///
1245/// Four conditions, each of which the rebuild depends on and none of which it
1246/// can check afterwards:
1247///
1248/// * every coedge's other face is the SAME neighbour — otherwise a corner IS a
1249/// triple point and belongs to `resolve_open_rim_end`;
1250/// * that neighbour is neither planar nor coaxial-ruled — the two lanes with
1251/// their own exact rebuilds, which must keep them (bit-identity);
1252/// * every edge is OPEN (a closed edge is the single-rim lane's business);
1253/// * every corner vertex has valence two across the WHOLE solid, so relocating
1254/// it cannot strand a third face's boundary. This is what rules out the hole
1255/// that straddles the pushed carrier's periodic seam: its arcs are stitched
1256/// into the outer loop, so the loop fails the first condition long before the
1257/// valence test — and either way it is refused rather than torn.
1258///
1259/// Returns `Ok(None)` for a loop that is simply not this shape (the outer loop
1260/// of any ordinary push), so the caller falls through to the lanes it always
1261/// used.
1262#[allow(clippy::too_many_arguments)]
1263fn single_neighbour_hole(
1264 loop_record: &crate::topology::LoopRecord,
1265 face_id: u64,
1266 solid: &BrepSolid,
1267 faces_of_edge: &HashMap<u64, Vec<u64>>,
1268 edge_by_id: &HashMap<u64, &EdgeRecord>,
1269 frame_origin: Vec3,
1270 frame_axis: Vec3,
1271 scale: f64,
1272) -> Result<Option<SingleNeighbourHole>, String> {
1273 let debug = std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok();
1274 let mut neighbour: Option<u64> = None;
1275 let mut edge_ids: Vec<u64> = Vec::new();
1276 for coedge in &loop_record.coedges {
1277 let edge = *edge_by_id
1278 .get(&coedge.edge_id)
1279 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1280 if edge.start_vertex_id == edge.end_vertex_id {
1281 if debug { eprintln!("HOLE reject: edge {} is closed", edge.id); }
1282 return Ok(None); // a closed rim: the single-rim lane owns it.
1283 }
1284 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
1285 let others: Vec<u64> = incident.into_iter().filter(|f| *f != face_id).collect();
1286 if others.len() != 1 {
1287 if debug { eprintln!("HOLE reject: edge {} has {} others", edge.id, others.len()); }
1288 return Ok(None); // a seam, or a non-manifold edge.
1289 }
1290 match neighbour {
1291 Some(known) if known != others[0] => {
1292 if debug { eprintln!("HOLE reject: mixed neighbours {known} / {}", others[0]); }
1293 return Ok(None);
1294 }
1295 Some(_) => {}
1296 None => neighbour = Some(others[0]),
1297 }
1298 if !edge_ids.contains(&edge.id) {
1299 edge_ids.push(edge.id);
1300 }
1301 }
1302 let Some(neighbour) = neighbour else {
1303 return Ok(None);
1304 };
1305 if edge_ids.len() < 2 {
1306 if debug { eprintln!("HOLE reject: only {} edges", edge_ids.len()); }
1307 return Ok(None);
1308 }
1309 let (nshell, nface) = find_face(solid, neighbour)
1310 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
1311 let surface = &solid.shells[nshell].faces[nface].surface;
1312 if matches!(surface.analytic(), Some(AnalyticSurface::Plane { .. }))
1313 || ruled_neighbour_is_coaxial(surface, frame_origin, frame_axis, scale)
1314 {
1315 if debug { eprintln!("HOLE reject: neighbour {neighbour} is planar/coaxial"); }
1316 return Ok(None); // the two lanes that already have exact rebuilds.
1317 }
1318 // Every corner is either a free bookkeeping split (nothing else ends there)
1319 // or PINNED to one edge of the neighbour — its seam. Anything else ending
1320 // at a corner makes it a genuine junction of three or more faces, which
1321 // this lane does not solve.
1322 let neighbour_edges: HashSet<u64> = solid.shells[nshell].faces[nface]
1323 .loops
1324 .iter()
1325 .flat_map(|loop_record| &loop_record.coedges)
1326 .map(|coedge| coedge.edge_id)
1327 .collect();
1328 let mut pinned: Vec<(u64, u64)> = Vec::new();
1329 for edge_id in &edge_ids {
1330 let edge = *edge_by_id
1331 .get(edge_id)
1332 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1333 for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1334 for other in &solid.edges {
1335 if other.start_vertex_id != vertex_id && other.end_vertex_id != vertex_id {
1336 continue;
1337 }
1338 if edge_ids.contains(&other.id) {
1339 continue;
1340 }
1341 if !neighbour_edges.contains(&other.id) {
1342 if debug {
1343 eprintln!("HOLE reject: vertex {vertex_id} also on foreign edge {}", other.id);
1344 }
1345 return Ok(None);
1346 }
1347 if pinned
1348 .iter()
1349 .any(|(known, pin)| *known == vertex_id && *pin != other.id)
1350 {
1351 if debug {
1352 eprintln!("HOLE reject: vertex {vertex_id} pinned by two neighbour edges");
1353 }
1354 return Ok(None);
1355 }
1356 if !pinned.iter().any(|(known, _)| *known == vertex_id) {
1357 pinned.push((vertex_id, other.id));
1358 }
1359 }
1360 }
1361 }
1362 if debug {
1363 eprintln!("HOLE accept: neighbour {neighbour} edges {edge_ids:?} pinned {pinned:?}");
1364 }
1365 Ok(Some(SingleNeighbourHole {
1366 neighbour,
1367 edge_ids,
1368 pinned,
1369 }))
1370}
1371
1372/// Rebuild a single-neighbour hole: re-intersect once, then cut the section.
1373#[allow(clippy::too_many_arguments)]
1374fn rebuild_single_neighbour_hole(
1375 solid: &BrepSolid,
1376 s_prime: &NurbsSurface,
1377 hole: &SingleNeighbourHole,
1378 edge_by_id: &HashMap<u64, &EdgeRecord>,
1379 vertex_pos: &HashMap<u64, Vec3>,
1380 tolerance: f64,
1381 scale: f64,
1382 new_curve: &mut HashMap<u64, NurbsCurve>,
1383 new_vertex: &mut HashMap<u64, Vec3>,
1384) -> Result<(), String> {
1385 let (nshell, nface) = find_face(solid, hole.neighbour)
1386 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {}", hole.neighbour))?;
1387 let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
1388
1389 // Seed the march with the WHOLE old loop: the section replacing it runs
1390 // near it for any push small against the feature, and a blind seed grid on
1391 // two large carriers can miss a small window entirely.
1392 let mut seeds: Vec<Vec3> = Vec::new();
1393 let mut reference: Vec<Vec3> = Vec::new();
1394 for edge_id in &hole.edge_ids {
1395 let edge = *edge_by_id
1396 .get(edge_id)
1397 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1398 let samples = edge_seeds(edge, 9)?;
1399 reference.extend(samples.iter().copied());
1400 seeds.extend(samples);
1401 }
1402 let policy = MarchPolicy {
1403 tolerance,
1404 residual_tolerance: (scale * 5e-4).max(5e-6),
1405 seeds,
1406 };
1407 let found = match reintersect_carriers(s_prime, neighbour_surface, &policy) {
1408 Ok(found) => found,
1409 Err(ReintersectRefusal::Separated) => {
1410 return Err(
1411 "offset_ruled_face: the pushed carrier no longer meets a neighbour \
1412 (the push separated them, or the rim left the neighbour's domain) — refusing"
1413 .into(),
1414 )
1415 }
1416 Err(other) => return Err(format!("offset_ruled_face: {}", other.describe())),
1417 };
1418 if found.lane != RimLane::Marched {
1419 // An analytic section has no polyline to cut, and the exact lanes that
1420 // produce one already have their own arc rebuild. Refusing here keeps
1421 // this lane from re-deciding a case the closed forms own.
1422 return Err(format!(
1423 "offset_ruled_face: the window bounded by face {} re-intersects in CLOSED FORM, \
1424 whose arc rebuild is the analytic lane's — deferred (refusing)",
1425 hole.neighbour
1426 ));
1427 }
1428 if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
1429 eprintln!(
1430 "HOLE rebuild neighbour {}: lane {:?}, {} branch(es), residual {:.3e} \
1431 (gate {:.3e})",
1432 hole.neighbour,
1433 found.lane,
1434 found.sections.len(),
1435 found.residual,
1436 policy.residual_tolerance
1437 );
1438 }
1439 // Which branch is THIS window: two windows cut by one crossing carrier
1440 // share a surface and so come back as two branches of one section set.
1441 let section = found
1442 .nearest_section(&reference)
1443 .map_err(|error| format!("offset_ruled_face: {error}"))?;
1444
1445 // Corners first, so every arc is cut against the same relocated vertices.
1446 //
1447 // A corner PINNED to the neighbour's seam is not free to go to the nearest
1448 // sample: it must stay on that meridian, so it goes where the new section
1449 // CROSSES the meridian's axial half-plane. Placing it at the nearest sample
1450 // instead leaves it off the seam by the amount the section drifted, which
1451 // the neighbour's own re-trim then reports as "a relocated rim vertex left
1452 // curved neighbour N's edge" — a refusal where a correct answer exists.
1453 for edge_id in &hole.edge_ids {
1454 let edge = *edge_by_id
1455 .get(edge_id)
1456 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1457 for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1458 if new_vertex.contains_key(&vertex_id) {
1459 continue;
1460 }
1461 let old = *vertex_pos
1462 .get(&vertex_id)
1463 .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
1464 let point = match hole
1465 .pinned
1466 .iter()
1467 .find(|(known, _)| *known == vertex_id)
1468 .map(|(_, pin)| *pin)
1469 {
1470 Some(pin) => {
1471 let seam = *edge_by_id
1472 .get(&pin)
1473 .ok_or_else(|| format!("offset_ruled_face: missing edge {pin}"))?;
1474 let plane = seam_axial_plane(neighbour_surface, seam).ok_or_else(|| {
1475 format!(
1476 "offset_ruled_face: the window's corner is pinned to edge {pin} of a \
1477 neighbour that is not a surface of revolution — refusing"
1478 )
1479 })?;
1480 // The correct crossing is inside the window itself, so the
1481 // search reach is the window's own extent about this corner
1482 // — the opposite meridian cannot win.
1483 let reach = reference
1484 .iter()
1485 .map(|point| point.sub(old).length())
1486 .fold(0.0f64, f64::max)
1487 .max(tolerance * 100.0);
1488 curve_plane_crossing_near(§ion.curve, &plane, old, reach)
1489 .map(|(_, point)| point)
1490 .ok_or_else(|| {
1491 format!(
1492 "offset_ruled_face: the rebuilt window never crosses the seam \
1493 (edge {pin}) its corner is pinned to — refusing"
1494 )
1495 })?
1496 }
1497 None => section_corner(section, old)
1498 .map_err(|error| format!("offset_ruled_face: {error}"))?,
1499 };
1500 new_vertex.insert(vertex_id, point);
1501 }
1502 }
1503 for edge_id in &hole.edge_ids {
1504 let edge = *edge_by_id
1505 .get(edge_id)
1506 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1507 let from = new_vertex[&edge.start_vertex_id];
1508 let to = new_vertex[&edge.end_vertex_id];
1509 let through = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
1510 let arc = arc_of_section(section, from, to, through, tolerance)
1511 .map_err(|error| format!("offset_ruled_face: {error}"))?;
1512 new_curve.insert(edge.id, arc);
1513 }
1514 Ok(())
1515}
1516
1517/// Re-fit ONLY the pcurves whose edge actually changed, each back onto the
1518/// periodic branch of `u` the pcurve it replaces was on.
1519///
1520/// This is the CURVED-neighbour lane's re-trim, and it is deliberately not
1521/// [`crate::offset_retrim::retrim_face_in_solid`]. That driver rebuilds *every*
1522/// pcurve of the face, which is right for a ruled revolution (whose seam
1523/// coedges are straight generatrices whose rebuilt pcurves land back on their
1524/// own sides by luck of the parameterization) and **measurably wrong** for a
1525/// sphere or a torus. Measured on the ball-capped rod, `d = +0.5`:
1526///
1527/// | face | rebuilt-all | correct (independently built r = 3.5 solid) |
1528/// |---|---|---|
1529/// | wall seam coedge | `u: 0 → 0` | `u: 1 → 1` |
1530/// | dome seam coedge | `u: 0 → 0` | `u: 1 → 1` |
1531/// | dome pole coedge | `(0,1) → (0,1)` | `(1,1) → (0,1)` |
1532///
1533/// with a resulting solid that `validate()` accepts and whose Green's-theorem
1534/// volume reads **207.86 against the true 534.10** — the dome's parameter-space
1535/// loop collapsed to zero area because both of its seam sides ended up on the
1536/// same branch. Exactly the failure [`reanchor_pcurve_u`] was written for.
1537///
1538/// Two rules together fix it, and both are needed:
1539/// * **touch only what moved.** A neighbour that keeps its surface keeps every
1540/// pcurve whose edge did not change; rebuilding one is a chance to land on
1541/// the wrong branch for no gain.
1542/// * **re-anchor what is rebuilt**, by the whole-period shift that aligns its
1543/// start with the pcurve it replaces — the rebuilt rim traverses the same path
1544/// in the same direction, so aligning the starts fixes the branch, and
1545/// [`reanchor_pcurve_u`]'s end-drift check refuses anything that is not
1546/// merely re-anchored.
1547fn refit_changed_pcurves(
1548 result: &mut BrepSolid,
1549 face_id: u64,
1550 final_edges: &HashMap<u64, EdgeRecord>,
1551 changed: &HashSet<u64>,
1552 tolerance: f64,
1553) -> Result<(), String> {
1554 let (shell, face_pos) = find_face(result, face_id)
1555 .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
1556 let surface = result.shells[shell].faces[face_pos].surface.clone();
1557 let [u_start, u_end] = surface.domain_u()?;
1558 let u_period = u_end - u_start;
1559 for loop_record in &mut result.shells[shell].faces[face_pos].loops {
1560 for coedge in &mut loop_record.coedges {
1561 if !changed.contains(&coedge.edge_id) {
1562 continue;
1563 }
1564 let edge = final_edges
1565 .get(&coedge.edge_id)
1566 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1567 // Same subrange rule as `PcurveFit::SubrangeAware`, so a rim that is
1568 // a strict piece of a full-domain curve keeps the range-aware fit it
1569 // has always had.
1570 let [d0, d1] = edge.curve.domain()?;
1571 let span = (d1 - d0).max(1e-12);
1572 let is_subrange =
1573 (edge.t0 - d0).abs() > 1e-9 * span || (edge.t1 - d1).abs() > 1e-9 * span;
1574 let pcurve = if is_subrange {
1575 build_pcurve_on_surface_range(
1576 &surface,
1577 &edge.curve,
1578 edge.t0,
1579 edge.t1,
1580 coedge.forward,
1581 tolerance,
1582 )?
1583 } else {
1584 let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
1585 if !coedge.forward {
1586 pcurve = pcurve.reversed()?;
1587 }
1588 pcurve
1589 };
1590 coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
1591 }
1592 }
1593 Ok(())
1594}
1595
1596/// The curve in `curves` whose midpoint is nearest `reference` (branch selection
1597/// for a surface∩surface intersection that returns more than one component).
1598fn nearest_curve(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
1599 let mut best: Option<(f64, &NurbsCurve)> = None;
1600 for curve in curves {
1601 let mid = curve.evaluate(0.5)?;
1602 let d = mid.sub(reference).length();
1603 if best.map(|(best_d, _)| d < best_d).unwrap_or(true) {
1604 best = Some((d, curve));
1605 }
1606 }
1607 best.map(|(_, curve)| curve.clone())
1608 .ok_or_else(|| "offset_ruled_face: empty intersection".into())
1609}
1610
1611// BREP private tests: 37e0c543416098c0