1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3
4pub fn offset_sphere_face(
79 solid: &BrepSolid,
80 face_id: u64,
81 distance: f64,
82) -> Result<BrepSolid, KernelRefusal> {
83 if !distance.is_finite() {
84 return Err(KernelRefusal::input(
85 KernelStage::Collect,
86 "distance",
87 "offset_sphere_face: distance must be finite",
88 ));
89 }
90 let scale = solid_model_scale(solid);
91 let tolerance = (scale * 1e-7).max(1e-9);
92 let plane_tolerance = (scale * 1e-6).max(1e-7);
93
94 let (pshell, pface) = find_face(solid, face_id)
95 .ok_or_else(|| {
96 KernelRefusal::input(
97 KernelStage::Collect,
98 "face_id",
99 format!("offset_sphere_face: no face {face_id}"),
100 )
101 })?;
102 let pushed = &solid.shells[pshell].faces[pface];
103 let Some(AnalyticSurface::Sphere { frame, radius }) = pushed.surface.analytic() else {
104 return Err(KernelRefusal::input(
105 KernelStage::Collect,
106 "not_sphere",
107 "offset_sphere_face: the pushed face is not a sphere",
108 ));
109 };
110 let center = frame.origin;
111 let axis = frame.axis;
112 let seam_x = frame.x_axis;
113 let seam_y = frame.y_axis;
114 let radius = *radius;
115 if radius <= tolerance {
116 return Err(KernelRefusal::input(
117 KernelStage::Collect,
118 "degenerate_radius",
119 "offset_sphere_face: degenerate sphere radius",
120 ));
121 }
122
123 let [u0, u1] = pushed.surface.domain_u().or_refuse(KernelStage::Classify, "domain_u")?;
128 let [v0, v1] = pushed.surface.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
129 let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
130 let point = pushed.surface.evaluate(um, vm).or_refuse(KernelStage::Classify, "evaluate")?;
131 let mut normal = pushed.surface.normal(um, vm).or_refuse(KernelStage::Classify, "normal")?;
132 if !pushed.same_sense {
133 normal = normal.scale(-1.0);
134 }
135 let radial = point.sub(center);
136 if radial.length() <= tolerance {
137 return Err(KernelRefusal::internal(
138 KernelStage::Classify,
139 "radial_direction",
140 "offset_sphere_face: degenerate radial direction",
141 ));
142 }
143 let outward_sign = if normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
144 let radius_new = radius + distance * outward_sign;
145 if radius_new <= tolerance {
146 return Err(KernelRefusal::input(
147 KernelStage::Classify,
148 "collapse",
149 "offset_sphere_face: the push collapses the sphere to or past its centre — refusing",
150 ));
151 }
152
153 let k = radius_new / radius;
156 let scale_about_center = AffineTransform::new([
157 k, 0.0, 0.0, (1.0 - k) * center.x,
158 0.0, k, 0.0, (1.0 - k) * center.y,
159 0.0, 0.0, k, (1.0 - k) * center.z,
160 0.0, 0.0, 0.0, 1.0,
161 ]).or_refuse(KernelStage::Refine, "affine_transform")?;
162 let s_prime = transform_surface(&pushed.surface, scale_about_center).or_refuse(KernelStage::Refine, "transform_surface")?;
163 match s_prime.analytic() {
165 Some(AnalyticSurface::Sphere { frame, radius }) => {
166 if frame.origin.sub(center).length() > plane_tolerance
167 || (radius - radius_new).abs() > plane_tolerance.max(radius_new * 1e-9)
168 {
169 return Err(KernelRefusal::internal(
170 KernelStage::Refine,
171 "offset_carrier",
172 "offset_sphere_face: offset carrier is not the expected \
173 concentric sphere — refusing",
174 ));
175 }
176 }
177 _ => {
178 return Err(KernelRefusal::internal(
179 KernelStage::Refine,
180 "offset_recognition",
181 "offset_sphere_face: offset carrier did not re-recognise as a sphere — refusing",
182 ))
183 }
184 }
185
186 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
190 for shell in &solid.shells {
191 for face in &shell.faces {
192 for loop_record in &face.loops {
193 for coedge in &loop_record.coedges {
194 faces_of_edge
195 .entry(coedge.edge_id)
196 .or_default()
197 .push(face.id);
198 }
199 }
200 }
201 }
202 let edge_by_id: HashMap<u64, &EdgeRecord> =
203 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
204
205 let [dv0, dv1] = s_prime.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
208
209 enum RimKind {
216 OffCentrePlane(Plane),
217 NeighbourSphere,
218 CoaxialRuled,
219 }
220 let mut edges_to_scale: HashSet<u64> = HashSet::default();
221 let mut through_centre_caps: HashSet<u64> = HashSet::default();
222 let mut own_only_edges: Vec<u64> = Vec::new();
223 let mut all_boundary_vertices: HashSet<u64> = HashSet::default();
224 let mut raw_rims: Vec<(u64, u64, RimKind)> = Vec::new();
225
226 for loop_record in &pushed.loops {
227 for coedge in &loop_record.coedges {
228 let edge = *edge_by_id
229 .get(&coedge.edge_id)
230 .ok_or_else(|| {
231 KernelRefusal::internal(
232 KernelStage::Classify,
233 "missing_edge",
234 format!("offset_sphere_face: missing edge {}", coedge.edge_id),
235 )
236 })?;
237 all_boundary_vertices.insert(edge.start_vertex_id);
238 all_boundary_vertices.insert(edge.end_vertex_id);
239
240 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
241 if incident.iter().all(|f| *f == face_id) {
242 own_only_edges.push(edge.id); continue;
244 }
245 let neighbour = *incident
246 .iter()
247 .find(|f| **f != face_id)
248 .ok_or_else(|| {
249 KernelRefusal::internal(
250 KernelStage::Classify,
251 "no_neighbour",
252 format!("offset_sphere_face: edge {} has no neighbour", edge.id),
253 )
254 })?;
255 let (nshell, nface) = find_face(solid, neighbour)
256 .ok_or_else(|| {
257 KernelRefusal::internal(
258 KernelStage::Classify,
259 "missing_neighbour",
260 format!("offset_sphere_face: missing neighbour {neighbour}"),
261 )
262 })?;
263 let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
264 match neighbour_surface.analytic() {
265 Some(AnalyticSurface::Plane { .. }) => {
266 let plane =
267 plane_of_surface(neighbour_surface, plane_tolerance, "offset_sphere_face")?;
268 if center.sub(plane.origin).dot(plane.normal).abs() <= plane_tolerance {
269 through_centre_caps.insert(neighbour);
271 edges_to_scale.insert(edge.id);
272 } else {
273 raw_rims.push((edge.id, neighbour, RimKind::OffCentrePlane(plane)));
274 }
275 }
276 Some(AnalyticSurface::Sphere { .. }) => {
277 raw_rims.push((edge.id, neighbour, RimKind::NeighbourSphere));
278 }
279 Some(AnalyticSurface::RuledRevolution { .. }) => {
280 if !super::face_offset::ruled_neighbour_is_coaxial(
281 neighbour_surface,
282 center,
283 axis,
284 scale,
285 ) {
286 return Err(KernelRefusal::unsupported(
287 KernelStage::Classify,
288 "non_coaxial_ruled",
289 "offset_sphere_face: a cylinder/cone rim neighbour is \
290 NON-coaxial (only coaxial sphere × ruled intersections are \
291 supported) — refusing",
292 ));
293 }
294 raw_rims.push((edge.id, neighbour, RimKind::CoaxialRuled));
295 }
296 Some(AnalyticSurface::Torus { .. }) => {
297 return Err(KernelRefusal::unsupported(
298 KernelStage::Classify,
299 "torus_neighbour",
300 "offset_sphere_face: a toroidal rim neighbour is deferred \
301 (torus is untouched in this slice)",
302 ));
303 }
304 _ => {
305 return Err(KernelRefusal::unsupported(
306 KernelStage::Classify,
307 "neighbour_kind",
308 "offset_sphere_face: a rim neighbour is not a supported plane, \
309 sphere, or coaxial cylinder/cone (general revolution and \
310 free-form neighbours are deferred in this slice)",
311 ));
312 }
313 }
314 }
315 }
316
317 let mut closure_vertices: HashSet<u64> = HashSet::default();
320 for (edge_id, _, _) in &raw_rims {
321 let edge = *edge_by_id.get(edge_id).unwrap();
322 if edge.start_vertex_id != edge.end_vertex_id {
323 return Err(KernelRefusal::unsupported(
324 KernelStage::Classify,
325 "open_rim",
326 "offset_sphere_face: a recomputed rim is not a single closed edge \
327 (multi-edge / open rims are deferred in this slice)",
328 ));
329 }
330 closure_vertices.insert(edge.start_vertex_id);
331 }
332
333 let mut coupled_meridians: HashSet<u64> = HashSet::default();
337 for own in &own_only_edges {
338 let e = *edge_by_id.get(own).unwrap();
339 if e.degenerate {
340 edges_to_scale.insert(*own);
341 continue;
342 }
343 if closure_vertices.contains(&e.start_vertex_id)
344 || closure_vertices.contains(&e.end_vertex_id)
345 {
346 coupled_meridians.insert(*own);
347 } else {
348 edges_to_scale.insert(*own);
349 }
350 }
351 let vertices_to_scale: HashSet<u64> = all_boundary_vertices
353 .iter()
354 .copied()
355 .filter(|v| !closure_vertices.contains(v))
356 .collect();
357
358 struct RimBuild {
360 edge_id: u64,
361 neighbour_id: u64,
362 neighbour_is_sphere: bool,
363 closure_vertex: u64,
364 circle: NurbsCurve,
365 closure_point: Vec3,
366 v_rim: f64,
367 pushed_pcurve: NurbsCurve,
368 }
369 let mut rim_builds: Vec<RimBuild> = Vec::new();
370 for (edge_id, neighbour, kind) in &raw_rims {
371 let edge = *edge_by_id.get(edge_id).unwrap();
372 let closure_vertex = edge.start_vertex_id;
373 let seam_coupled = coupled_meridians.iter().any(|m| {
374 let e = *edge_by_id.get(m).unwrap();
375 e.start_vertex_id == closure_vertex || e.end_vertex_id == closure_vertex
376 });
377
378 let mut circle = if seam_coupled {
379 match kind {
385 RimKind::OffCentrePlane(plane) => {
386 if plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
387 return Err(KernelRefusal::unsupported(
388 KernelStage::Classify,
389 "oblique_seam_rim",
390 "offset_sphere_face: a seam-crossing rim on an OBLIQUE plane is \
391 deferred in this slice (only axis-perpendicular caps)",
392 ));
393 }
394 let a = plane.origin.sub(center).dot(axis);
395 let rr2 = radius_new * radius_new - a * a;
396 if rr2 <= tolerance * tolerance {
397 return Err(KernelRefusal::unsupported(
398 KernelStage::Refine,
399 "cap_vanishes",
400 "offset_sphere_face: the push pulls the grown sphere off its cap \
401 plane (the cap vanishes) — refusing",
402 ));
403 }
404 crate::make_arc(
405 center.add(axis.scale(a)),
406 seam_x,
407 seam_y,
408 rr2.sqrt(),
409 0.0,
410 std::f64::consts::TAU,
411 ).or_refuse(KernelStage::Refine, "make_arc")?
412 }
413 RimKind::NeighbourSphere => {
414 return Err(KernelRefusal::unsupported(
415 KernelStage::Classify,
416 "sphere_seam_rim",
417 "offset_sphere_face: a sphere neighbour whose rim crosses the \
418 pushed sphere's seam is deferred in this slice",
419 ))
420 }
421 RimKind::CoaxialRuled => {
422 let (ns, nf) = find_face(solid, *neighbour).unwrap();
428 let neighbour_surface = &solid.shells[ns].faces[nf].surface;
429 let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
430 .filter(|curves| !curves.is_empty())
431 .ok_or_else(|| {
432 KernelRefusal::unsupported(
433 KernelStage::Refine,
434 "ruled_separated",
435 "offset_sphere_face: the grown sphere no longer meets its coaxial \
436 cylinder/cone neighbour — refusing",
437 )
438 })?;
439 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Refine, "evaluate")?;
440 nearest_circle(&curves, old_mid)?
441 }
442 }
443 } else {
444 let (ns, nf) = find_face(solid, *neighbour).unwrap();
445 let neighbour_surface = &solid.shells[ns].faces[nf].surface;
446 let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
447 .filter(|curves| !curves.is_empty())
448 .ok_or_else(|| {
449 KernelRefusal::unsupported(
450 KernelStage::Refine,
451 "separated",
452 "offset_sphere_face: the grown sphere no longer meets a neighbour (the cap \
453 vanishes / tangent / separated) — refusing",
454 )
455 })?;
456 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Refine, "evaluate")?;
457 nearest_circle(&curves, old_mid)?
458 };
459
460 let closure_old = edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Refine, "evaluate")?;
463 let old_tan = edge
464 .curve
465 .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0)).or_refuse(KernelStage::Refine, "evaluate")?
466 .sub(closure_old);
467 let [c0, c1] = circle.domain().or_refuse(KernelStage::Refine, "domain")?;
468 let new_start = circle.evaluate(c0).or_refuse(KernelStage::Refine, "evaluate")?;
469 let new_tan = circle.evaluate(c0 + 0.01 * (c1 - c0)).or_refuse(KernelStage::Refine, "evaluate")?.sub(new_start);
470 if old_tan.dot(new_tan) < 0.0 {
471 circle = circle.reversed().or_refuse(KernelStage::Refine, "reversed")?;
472 }
473 let closure_point = circle.evaluate(circle.domain().or_refuse(KernelStage::Refine, "domain")?[0]).or_refuse(KernelStage::Refine, "evaluate")?;
474 let pushed_pcurve = build_pcurve_on_surface(&s_prime, &circle).or_refuse(KernelStage::Refine, "build_pcurve_on_surface")?;
475 let [q0, q1] = pushed_pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
478 let v_rim = pushed_pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Refine, "evaluate")?.y;
479
480 rim_builds.push(RimBuild {
481 edge_id: *edge_id,
482 neighbour_id: *neighbour,
483 neighbour_is_sphere: matches!(kind, RimKind::NeighbourSphere),
484 closure_vertex,
485 circle,
486 closure_point,
487 v_rim,
488 pushed_pcurve,
489 });
490 }
491
492 let iso_meridian = s_prime.iso_curve_u(0.0).or_refuse(KernelStage::Refine, "iso_curve_u")?;
496
497 let mut result = solid.clone();
499
500 for edge in &mut result.edges {
502 if edges_to_scale.contains(&edge.id) {
503 edge.curve = transform_curve(&edge.curve, scale_about_center).or_refuse(KernelStage::Fragment, "transform_curve")?;
504 }
505 }
506 for rb in &rim_builds {
508 if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rb.edge_id) {
509 let [d0, d1] = rb.circle.domain().or_refuse(KernelStage::Fragment, "domain")?;
510 edge.curve = rb.circle.clone();
511 edge.t0 = d0;
512 edge.t1 = d1;
513 }
514 }
515 for mer in &coupled_meridians {
518 let e = *edge_by_id.get(mer).unwrap();
519 let cv = if closure_vertices.contains(&e.start_vertex_id) {
520 e.start_vertex_id
521 } else {
522 e.end_vertex_id
523 };
524 let v_rim = rim_builds
525 .iter()
526 .find(|rb| rb.closure_vertex == cv)
527 .map(|rb| rb.v_rim)
528 .ok_or_else(|| {
529 KernelRefusal::internal(
530 KernelStage::Fragment,
531 "meridian_pairing",
532 "offset_sphere_face: a seam meridian is not paired with a rim — refusing",
533 )
534 })?;
535 let closure_at_start = e.start_vertex_id == cv;
536 let fixed_vertex = if closure_at_start {
537 e.end_vertex_id
538 } else {
539 e.start_vertex_id
540 };
541 let fixed_old = solid
542 .vertices
543 .iter()
544 .find(|v| v.id == fixed_vertex)
545 .map(|v| v.point)
546 .ok_or_else(|| {
547 KernelRefusal::internal(
548 KernelStage::Fragment,
549 "seam_vertex",
550 format!("offset_sphere_face: missing seam vertex {fixed_vertex}"),
551 )
552 })?;
553 let fixed_new = scale_about_center.point(fixed_old);
554 let fixed_projection = project_point_to_curve(&iso_meridian, fixed_new).or_refuse(KernelStage::Fragment, "project_point_to_curve")?;
555 if fixed_projection.distance > plane_tolerance {
556 return Err(KernelRefusal::internal(
557 KernelStage::Fragment,
558 "seam_projection",
559 format!(
560 "offset_sphere_face: a coupled own-edge is not on the sphere seam \
561 (off by {:.3e}) — refusing",
562 fixed_projection.distance
563 ),
564 ));
565 }
566 let v_start = if closure_at_start {
567 v_rim
568 } else {
569 fixed_projection.u
570 };
571 let v_end = if closure_at_start {
572 fixed_projection.u
573 } else {
574 v_rim
575 };
576 let (curve, t0, t1) = if v_start <= v_end {
577 (iso_meridian.clone(), v_start, v_end)
578 } else {
579 (
580 iso_meridian.reversed().or_refuse(KernelStage::Fragment, "reversed")?,
581 dv0 + dv1 - v_start,
582 dv0 + dv1 - v_end,
583 )
584 };
585 if let Some(edge) = result.edges.iter_mut().find(|x| x.id == *mer) {
586 edge.curve = curve;
587 edge.t0 = t0;
588 edge.t1 = t1;
589 }
590 }
591
592 for vertex in &mut result.vertices {
594 if vertices_to_scale.contains(&vertex.id) {
595 vertex.point = scale_about_center.point(vertex.point);
596 }
597 }
598 for rb in &rim_builds {
599 if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rb.closure_vertex) {
600 v.point = rb.closure_point;
601 }
602 }
603
604 {
608 let pface_rec = &mut result.shells[pshell].faces[pface];
609 for loop_record in &mut pface_rec.loops {
610 for coedge in &mut loop_record.coedges {
611 if let Some(rb) = rim_builds.iter().find(|rb| rb.edge_id == coedge.edge_id) {
612 let mut pcurve = rb.pushed_pcurve.clone();
613 if !coedge.forward {
614 pcurve = pcurve.reversed().or_refuse(KernelStage::Fragment, "reversed")?;
615 }
616 coedge.pcurve = pcurve;
617 } else if coupled_meridians.contains(&coedge.edge_id) {
618 let e = *edge_by_id.get(&coedge.edge_id).unwrap();
619 let cv = if closure_vertices.contains(&e.start_vertex_id) {
620 e.start_vertex_id
621 } else {
622 e.end_vertex_id
623 };
624 let v_rim = rim_builds
625 .iter()
626 .find(|rb| rb.closure_vertex == cv)
627 .map(|rb| rb.v_rim)
628 .unwrap();
629 let closure_at_pcurve_start = (e.start_vertex_id == cv) == coedge.forward;
630 coedge.pcurve =
631 patch_meridian_pcurve(&coedge.pcurve, v_rim, closure_at_pcurve_start)?;
632 }
633 }
634 }
635 }
636 result.shells[pshell].faces[pface].surface = s_prime;
637
638 let result_vertex: HashMap<u64, Vec3> =
642 result.vertices.iter().map(|v| (v.id, v.point)).collect();
643 let mut ruled_seams: HashSet<u64> = HashSet::default();
644 for rb in &rim_builds {
645 let (ns, nf) = find_face(&result, rb.neighbour_id).ok_or_else(|| {
646 KernelRefusal::internal(
647 KernelStage::Fragment,
648 "ruled_seam_neighbour",
649 format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id),
650 )
651 })?;
652 if !matches!(
653 result.shells[ns].faces[nf].surface.analytic(),
654 Some(AnalyticSurface::RuledRevolution { .. })
655 ) {
656 continue;
657 }
658 for loop_record in &result.shells[ns].faces[nf].loops {
659 for coedge in &loop_record.coedges {
660 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
661 KernelRefusal::internal(
662 KernelStage::Fragment,
663 "ruled_seam_edge",
664 format!("offset_sphere_face: missing edge {}", coedge.edge_id),
665 )
666 })?;
667 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
668 let is_seam = incident.iter().all(|f| *f == rb.neighbour_id);
669 let touches_rim = closure_vertices.contains(&edge.start_vertex_id)
670 || closure_vertices.contains(&edge.end_vertex_id);
671 if is_seam && touches_rim {
672 ruled_seams.insert(edge.id);
673 }
674 }
675 }
676 }
677 let rebuilt_ruled_seams: HashSet<u64> = ruled_seams.iter().copied().collect();
678 for seam_id in ruled_seams {
679 let edge = *edge_by_id
680 .get(&seam_id)
681 .ok_or_else(|| {
682 KernelRefusal::internal(
683 KernelStage::Fragment,
684 "missing_ruled_seam",
685 format!("offset_sphere_face: missing ruled seam {seam_id}"),
686 )
687 })?;
688 let curve = make_line(
689 result_vertex[&edge.start_vertex_id],
690 result_vertex[&edge.end_vertex_id],
691 ).or_refuse(KernelStage::Fragment, "make_line")?;
692 if let Some(out) = result
693 .edges
694 .iter_mut()
695 .find(|candidate| candidate.id == seam_id)
696 {
697 let [t0, t1] = curve.domain().or_refuse(KernelStage::Fragment, "domain")?;
698 out.curve = curve;
699 out.t0 = t0;
700 out.t1 = t1;
701 }
702 }
703
704 let mut relocated: HashMap<u64, Vec3> = HashMap::default();
730 for vertex in &result.vertices {
731 let Some(old) = solid.vertices.iter().find(|o| o.id == vertex.id) else {
732 continue;
733 };
734 if vertex.point.sub(old.point).length() > tolerance {
735 relocated.insert(vertex.id, vertex.point);
736 }
737 }
738 let already_rebuilt: HashSet<u64> = edges_to_scale
739 .iter()
740 .copied()
741 .chain(rim_builds.iter().map(|rb| rb.edge_id))
742 .chain(coupled_meridians.iter().copied())
743 .chain(rebuilt_ruled_seams.iter().copied())
744 .collect();
745 let mut resized_edges: HashSet<u64> = HashSet::default();
746 for edge in &mut result.edges {
747 if edge.degenerate || already_rebuilt.contains(&edge.id) {
748 continue;
749 }
750 for at_start in [true, false] {
751 let vertex_id = if at_start {
752 edge.start_vertex_id
753 } else {
754 edge.end_vertex_id
755 };
756 let Some(&target) = relocated.get(&vertex_id) else {
757 continue;
758 };
759 let projection = project_point_to_curve(&edge.curve, target).or_refuse(KernelStage::Fragment, "project_point_to_curve")?;
760 if projection.distance > 10.0 * tolerance {
761 return Err(KernelRefusal::unsupported(
762 KernelStage::Fragment,
763 "corner_off_edge",
764 format!(
765 "offset_sphere_face: the push moves rim corner (vertex {vertex_id}) OFF \
766 fixed edge {} by {:.3e} — a rim corner that leaves its own side edge \
767 needs the corner re-solved against the neighbour carriers, which is \
768 deferred — refusing",
769 edge.id, projection.distance
770 ),
771 ));
772 }
773 let [d0, d1] = edge.curve.domain().or_refuse(KernelStage::Fragment, "domain")?;
774 let span = (d1 - d0).max(1e-12);
775 let fixed_t = if at_start { edge.t1 } else { edge.t0 };
776 let old_t = if at_start { edge.t0 } else { edge.t1 };
777 let new_t = projection.u;
778 if (new_t - fixed_t) * (old_t - fixed_t) <= 0.0
779 || (new_t - fixed_t).abs() <= 1e-7 * span
780 {
781 return Err(KernelRefusal::unsupported(
782 KernelStage::Fragment,
783 "trim_collapse",
784 format!(
785 "offset_sphere_face: the push collapses or inverts the trim of fixed edge \
786 {} at rim corner (vertex {vertex_id}) — refusing",
787 edge.id
788 ),
789 ));
790 }
791 if at_start {
792 edge.t0 = new_t;
793 } else {
794 edge.t1 = new_t;
795 }
796 resized_edges.insert(edge.id);
797 }
798 }
799
800 let final_edges: HashMap<u64, EdgeRecord> =
802 result.edges.iter().map(|e| (e.id, e.clone())).collect();
803 for rb in &rim_builds {
804 let (ns, nf) = find_face(&result, rb.neighbour_id).ok_or_else(|| {
805 KernelRefusal::internal(
806 KernelStage::Sew,
807 "retrim_neighbour",
808 format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id),
809 )
810 })?;
811 if rb.neighbour_is_sphere {
812 let nsurf = result.shells[ns].faces[nf].surface.clone();
814 for loop_record in &mut result.shells[ns].faces[nf].loops {
815 for coedge in &mut loop_record.coedges {
816 if coedge.edge_id == rb.edge_id {
817 let mut pcurve = build_pcurve_on_surface(&nsurf, &rb.circle).or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
818 if !coedge.forward {
819 pcurve = pcurve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
820 }
821 coedge.pcurve = pcurve;
822 }
823 }
824 }
825 } else if matches!(
826 result.shells[ns].faces[nf].surface.analytic(),
827 Some(AnalyticSurface::RuledRevolution { .. })
828 ) {
829 super::face_offset::retrim_offset_ruled_face(
830 &mut result,
831 rb.neighbour_id,
832 &final_edges,
833 tolerance,
834 )?;
835 } else {
836 let plane = plane_of_surface(
837 &result.shells[ns].faces[nf].surface,
838 plane_tolerance,
839 "offset_sphere_face",
840 )?;
841 retrim_planar_face(
842 &mut result.shells[ns].faces[nf],
843 &plane,
844 &final_edges,
845 scale,
846 "offset_sphere_face",
847 )?;
848 refit_subrange_pcurves(
849 &mut result.shells[ns].faces[nf],
850 &final_edges,
851 tolerance,
852 )?;
853 }
854 }
855 for cap in &through_centre_caps {
857 let (cshell, cface) = find_face(&result, *cap)
858 .ok_or_else(|| {
859 KernelRefusal::internal(
860 KernelStage::Sew,
861 "missing_cap",
862 format!("offset_sphere_face: missing cap {cap}"),
863 )
864 })?;
865 let plane = plane_of_surface(
866 &result.shells[cshell].faces[cface].surface,
867 plane_tolerance,
868 "offset_sphere_face",
869 )?;
870 retrim_planar_face(
871 &mut result.shells[cshell].faces[cface],
872 &plane,
873 &final_edges,
874 scale,
875 "offset_sphere_face",
876 )?;
877 refit_subrange_pcurves(
878 &mut result.shells[cshell].faces[cface],
879 &final_edges,
880 tolerance,
881 )?;
882 }
883 for shell in &mut result.shells {
887 for face in &mut shell.faces {
888 if face
889 .loops
890 .iter()
891 .flat_map(|l| &l.coedges)
892 .any(|c| resized_edges.contains(&c.edge_id))
893 {
894 refit_touched_pcurves(
895 face,
896 &final_edges,
897 &resized_edges,
898 true,
899 tolerance,
900 "offset_sphere_face",
901 )?;
902 }
903 }
904 }
905
906 let issues = result.validate();
907 if !issues.is_empty() {
908 return Err(KernelRefusal::internal(
909 KernelStage::Validate,
910 "validate",
911 format!(
912 "offset_sphere_face: pushed solid failed validation: {issues:?}"
913 ),
914 ));
915 }
916 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
917 if before * after <= 0.0 {
918 return Err(KernelRefusal::internal(
919 KernelStage::Validate,
920 INVERTED,
921 "offset_sphere_face: the push inverts the solid — refusing",
922 ));
923 }
924 }
925 Ok(result)
926}
927
928fn refit_subrange_pcurves(
946 face: &mut FaceRecord,
947 edges: &HashMap<u64, EdgeRecord>,
948 tolerance: f64,
949) -> Result<(), KernelRefusal> {
950 let face_edges: HashSet<u64> = face
951 .loops
952 .iter()
953 .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
954 .collect();
955 refit_touched_pcurves(
956 face,
957 edges,
958 &face_edges,
959 true,
960 tolerance,
961 "offset_sphere_face",
962 )
963
964}
965
966fn nearest_circle(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, KernelRefusal> {
969 let mut best: Option<(f64, &NurbsCurve)> = None;
970 for curve in curves {
971 let [d0, d1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
972 let mid = curve.evaluate(0.5 * (d0 + d1)).or_refuse(KernelStage::Refine, "evaluate")?;
973 let distance = mid.sub(reference).length();
974 if best.map(|(known, _)| distance < known).unwrap_or(true) {
975 best = Some((distance, curve));
976 }
977 }
978 best.map(|(_, curve)| curve.clone())
979 .ok_or_else(|| {
980 KernelRefusal::internal(
981 KernelStage::Refine,
982 "empty_intersection",
983 "offset_sphere_face: the sphere ∩ neighbour intersection is empty",
984 )
985 })
986}
987
988fn patch_meridian_pcurve(
993 pcurve: &NurbsCurve,
994 v_new: f64,
995 closure_at_start: bool,
996) -> Result<NurbsCurve, KernelRefusal> {
997 let [q0, q1] = pcurve.domain().or_refuse(KernelStage::Fragment, "domain")?;
998 let start = pcurve.evaluate(q0).or_refuse(KernelStage::Fragment, "evaluate")?;
999 let end = pcurve.evaluate(q1).or_refuse(KernelStage::Fragment, "evaluate")?;
1000 let (v_start, v_end) = if closure_at_start {
1001 (v_new, end.y)
1002 } else {
1003 (start.y, v_new)
1004 };
1005 make_line(
1006 Vec3::new(start.x, v_start, 0.0),
1007 Vec3::new(end.x, v_end, 0.0),
1008 )
1009 .or_refuse(KernelStage::Fragment, "make_line")
1010}
1011