1use crate::wire2d::Join;
34use ogeom_algo::{
35 Built, History, edge_vertices, make_edge, make_edge_between, make_face_with_pcurves,
36 make_revolution_band, make_vertex, sew,
37};
38use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
39use ogeom_geom::Curve3d as _;
40use ogeom_geom::{Curve, CylinderSurface, LineCurve, PlaneSurface, SurfaceGeometry};
41use ogeom_math::{Cylinder, Frame, Plane, Point, Vector};
42use ogeom_topo::{
43 EdgeData, EdgeRepr, Filter, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
44 explore_unique,
45};
46
47use ogeom_core::FastMap;
48
49fn is_free_form(surface: &SurfaceGeometry) -> bool {
52 !matches!(
53 surface,
54 SurfaceGeometry::Plane(_)
55 | SurfaceGeometry::Cylinder(_)
56 | SurfaceGeometry::Cone(_)
57 | SurfaceGeometry::Sphere(_)
58 | SurfaceGeometry::Torus(_)
59 )
60}
61
62type Displacement<'a> = dyn Fn(&Model, usize, Point) -> OgeomResult<Option<(Vector, f64)>> + 'a;
64
65pub(crate) fn canonical_input(
77 model: &mut Model,
78 solid: &Shape,
79 handles: &[Shape],
80 tol: Tolerances,
81) -> OgeomResult<(Shape, Vec<Shape>, Option<ogeom_algo::History>)> {
82 let probe = Point::new(0.123_456_789, 9.87, -3.21);
83 let mut seen: FastMap<TShapeId, Point> = FastMap::default();
84 let mut instanced = false;
85 'outer: for kind in [ShapeType::Vertex, ShapeType::Edge, ShapeType::Face] {
86 for occurrence in explore(model, solid, Filter::OfType(kind))? {
87 let at = occurrence.transform(model.datums())?.apply(probe);
88 if at.distance(probe) > tol.confusion() {
89 instanced = true;
90 break 'outer;
91 }
92 match seen.entry(occurrence.node()) {
93 ogeom_core::collections::hash_map::Entry::Occupied(held) => {
94 if held.get().distance(at) > tol.confusion() {
95 instanced = true;
96 break 'outer;
97 }
98 }
99 ogeom_core::collections::hash_map::Entry::Vacant(slot) => {
100 slot.insert(at);
101 }
102 }
103 }
104 }
105 if !instanced {
106 return Ok((solid.clone(), handles.to_vec(), None));
107 }
108 let baked = ogeom_algo::baked_shape(model, solid, tol)?;
109 let mapped = handles
110 .iter()
111 .map(|h| match baked.history.trace(h) {
112 [one] => Ok(one.clone()),
113 traced => ogeom_bail!(
114 Construction,
115 "a face handle resolved to {} faces through the canonical \
116 rebuild; the reference is ambiguous",
117 traced.len()
118 ),
119 })
120 .collect::<OgeomResult<Vec<Shape>>>()?;
121 Ok((baked.shape, mapped, Some(baked.history)))
122}
123
124pub fn offset_shape(
136 model: &mut Model,
137 solid: &Shape,
138 offset: f64,
139 tol: Tolerances,
140) -> OgeomResult<Built> {
141 if !offset.is_finite() || offset.abs() <= tol.confusion() {
142 ogeom_bail!(Construction, "an offset of {offset} moves nothing");
143 }
144 let (canonical, _, prefix) = canonical_input(model, solid, &[], tol)?;
145 if let Some(prefix) = prefix {
146 let mut out = offset_shape(model, &canonical, offset, tol)?;
147 out.history = prefix.then(&out.history);
148 return Ok(out);
149 }
150 let built = rebuilt(model, solid, &|_| offset, &|_| None, tol)?;
151 right_side_out(model, built, tol)
152}
153
154fn right_side_out(model: &Model, built: Built, tol: Tolerances) -> OgeomResult<Built> {
160 if !ogeom_algo::inside_out_faces(model, &built.shape, tol)?.is_empty() {
161 ogeom_bail!(
162 Construction,
163 "the offset runs the solid's faces through each other and \
164 collapses it"
165 );
166 }
167 Ok(built)
168}
169
170pub fn offset_faces(
185 model: &mut Model,
186 solid: &Shape,
187 faces: &[Shape],
188 distance: f64,
189 tol: Tolerances,
190) -> OgeomResult<Built> {
191 if !distance.is_finite() || distance.abs() <= tol.confusion() {
192 ogeom_bail!(Construction, "an offset of {distance} moves nothing");
193 }
194 let (canonical, mapped, prefix) = canonical_input(model, solid, faces, tol)?;
195 if let Some(prefix) = prefix {
196 let mut out = offset_faces(model, &canonical, &mapped, distance, tol)?;
197 out.history = prefix.then(&out.history);
198 return Ok(out);
199 }
200 let chosen = chosen_faces(model, solid, faces)?;
201 let built = rebuilt(
202 model,
203 solid,
204 &|face| {
205 if chosen.contains(&face.node()) {
206 distance
207 } else {
208 0.0
209 }
210 },
211 &|_| None,
212 tol,
213 )?;
214 still_sound(model, solid, &chosen, built, tol)
215}
216
217pub fn move_faces(
227 model: &mut Model,
228 solid: &Shape,
229 faces: &[Shape],
230 transform: &ogeom_math::Transform,
231 tol: Tolerances,
232) -> OgeomResult<Built> {
233 use ogeom_geom::Transformable as _;
234 use ogeom_math::TransformKind;
235 if !matches!(
236 transform.kind(),
237 TransformKind::Identity | TransformKind::Translation | TransformKind::Rotation
238 ) {
239 ogeom_bail!(
240 Construction,
241 "moving faces takes a translation or a rotation; a scale or a \
242 reflection reshapes them"
243 );
244 }
245 if transform.kind() == TransformKind::Identity {
246 ogeom_bail!(Construction, "the identity moves nothing");
247 }
248 let (canonical, mapped, prefix) = canonical_input(model, solid, faces, tol)?;
249 if let Some(prefix) = prefix {
250 let mut out = move_faces(model, &canonical, &mapped, transform, tol)?;
251 out.history = prefix.then(&out.history);
252 return Ok(out);
253 }
254 let chosen = chosen_faces(model, solid, faces)?;
255 let mut moved: FastMap<TShapeId, SurfaceGeometry> = FastMap::default();
258 for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
259 if !chosen.contains(&face.node()) || moved.contains_key(&face.node()) {
260 continue;
261 }
262 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
263 ogeom_bail!(Construction, "face node holds no face data");
264 };
265 let Some(surface) = model.geometry().surface(data.surface) else {
266 ogeom_bail!(Dangling, "face refers to a surface not in this model");
267 };
268 let placement = face.transform(model.datums())?;
269 let local = placement.inverse()? * *transform * placement;
270 moved.insert(face.node(), surface.transformed(&local, tol)?);
271 }
272 let built = rebuilt(
273 model,
274 solid,
275 &|_| 0.0,
276 &|face| moved.get(&face.node()).cloned(),
277 tol,
278 )?;
279 still_sound(model, solid, &chosen, built, tol)
280}
281
282fn still_sound(
289 model: &Model,
290 solid: &Shape,
291 chosen: &ogeom_core::FastSet<TShapeId>,
292 built: Built,
293 tol: Tolerances,
294) -> OgeomResult<Built> {
295 let faces = explore_unique(model, solid, ShapeType::Face)?;
296 let mut corners: ogeom_core::FastSet<TShapeId> = ogeom_core::FastSet::default();
297 for face in faces.iter().filter(|f| chosen.contains(&f.node())) {
298 for v in explore_unique(model, face, ShapeType::Vertex)? {
299 corners.insert(v.node());
300 }
301 }
302 let mut touched: Vec<Shape> = Vec::new();
303 for face in &faces {
304 let near = chosen.contains(&face.node())
305 || explore_unique(model, face, ShapeType::Vertex)?
306 .iter()
307 .any(|v| corners.contains(&v.node()));
308 if near {
309 touched.extend(built.history.trace(face).iter().cloned());
310 }
311 }
312 if !ogeom_algo::inside_out_faces(model, &built.shape, tol)?.is_empty() {
313 ogeom_bail!(
314 Construction,
315 "the moved faces run past the faces across from them and turn \
316 the solid inside out"
317 );
318 }
319 if !ogeom_algo::check_self_intersection_near(model, &built.shape, &touched, tol)?.is_empty() {
320 ogeom_bail!(
321 Construction,
322 "the moved faces run into the rest of the solid; the edit would \
323 make it cross itself"
324 );
325 }
326 Ok(built)
327}
328
329fn chosen_faces(
331 model: &Model,
332 solid: &Shape,
333 faces: &[Shape],
334) -> OgeomResult<ogeom_core::FastSet<TShapeId>> {
335 if faces.is_empty() {
336 ogeom_bail!(Construction, "no face was named to move");
337 }
338 let own: ogeom_core::FastSet<TShapeId> =
339 explore(model, solid, Filter::OfType(ShapeType::Face))?
340 .iter()
341 .map(Shape::node)
342 .collect();
343 for face in faces {
344 if !own.contains(&face.node()) {
345 ogeom_bail!(Construction, "a named face is not a face of the solid");
346 }
347 }
348 Ok(faces.iter().map(Shape::node).collect())
349}
350
351pub fn make_thick_solid(
372 model: &mut Model,
373 solid: &Shape,
374 removed: &[Shape],
375 thickness: f64,
376 tol: Tolerances,
377) -> OgeomResult<Built> {
378 make_thick_solid_with(model, solid, removed, thickness, Join::Intersection, tol)
379}
380
381pub fn make_thick_solid_with(
396 model: &mut Model,
397 solid: &Shape,
398 removed: &[Shape],
399 thickness: f64,
400 join: Join,
401 tol: Tolerances,
402) -> OgeomResult<Built> {
403 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
404 ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
405 }
406 let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
407 if let Some(prefix) = prefix {
408 let mut out = make_thick_solid_with(model, &canonical, &mapped, thickness, join, tol)?;
409 out.history = prefix.then(&out.history);
410 return Ok(out);
411 }
412 let outward_walls = thickness < 0.0;
415 let reach = thickness.abs();
416 let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
417 .iter()
418 .map(Shape::node)
419 .collect();
420 for face in removed {
421 if !own.contains(&face.node()) {
422 ogeom_bail!(Construction, "a removed face is not a face of the solid");
423 }
424 }
425
426 let mut tangent_opening = false;
427 for face in removed {
428 if has_tangent_neighbour(model, solid, face, tol)? {
429 tangent_opening = true;
430 break;
431 }
432 }
433 if !tangent_opening {
434 let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
435 let moved = rebuilt(
436 model,
437 solid,
438 &|face| {
439 if skip.contains(&face.node()) {
440 0.0
441 } else if outward_walls {
442 reach
443 } else {
444 -reach
445 }
446 },
447 &|_| None,
448 tol,
449 )?;
450 let moved = right_side_out(model, moved, tol)?;
451 let moved = if join == Join::Arc {
455 let held: Vec<TShapeId> = removed
456 .iter()
457 .flat_map(|f| {
458 moved
459 .history
460 .modified(f)
461 .iter()
462 .map(Shape::node)
463 .collect::<Vec<_>>()
464 })
465 .collect();
466 let edges = growing_edges(model, &moved.shape, &held, outward_walls, tol)?;
467 if edges.is_empty() {
468 moved
469 } else {
470 ogeom_fillet::fillet_edges(model, &moved.shape, &edges, reach, tol)?
471 }
472 } else {
473 moved
474 };
475 let mut result = if outward_walls {
480 ogeom_bool::cut(model, &moved.shape, solid, tol)?
481 } else {
482 ogeom_bool::cut(model, solid, &moved.shape, tol)?
483 };
484 for face in removed {
485 result.history.delete(face);
486 }
487 return Ok(result);
488 }
489
490 if join == Join::Arc {
491 ogeom_bail!(
492 Construction,
493 "the arc join is built where every opening meets its neighbours \
494 across a corner; an opening with a tangent neighbour is not yet"
495 );
496 }
497 let displaced = if outward_walls { reach } else { -reach };
501 let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
502 let moved = right_side_out(model, moved, tol)?;
503 let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
504 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
505 ogeom_bail!(Construction, "face node holds no face data");
506 };
507 let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
508 ogeom_bail!(
509 Construction,
510 "a tangent opening must be planar; a curved opening needs \
511 the general rebuild; see docs/CAPABILITIES.md, offset.shell-thicken"
512 );
513 };
514 let mut normal = p.plane().normal().vector();
515 if face.orientation() == Orientation::Reversed {
516 normal = -normal;
517 }
518 Ok(normal)
519 };
520 let mut result = if outward_walls {
521 let mut tool = solid.clone();
524 for face in removed {
525 let outward = opening_normal(model, face)?;
526 let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
527 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
528 }
529 ogeom_bool::cut(model, &moved.shape, &tool, tol)?
530 } else {
531 let mut tool = moved.shape.clone();
532 for face in removed {
533 let outward = opening_normal(model, face)?;
534 let [image] = moved.history.modified(face) else {
535 ogeom_bail!(Construction, "a removed face has no single cavity image");
536 };
537 let punch =
538 ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
539 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
540 }
541 ogeom_bool::cut(model, solid, &tool, tol)?
542 };
543 for face in removed {
544 result.history.delete(face);
545 }
546 Ok(result)
547}
548
549fn has_tangent_neighbour(
551 model: &Model,
552 solid: &Shape,
553 face: &Shape,
554 tol: Tolerances,
555) -> OgeomResult<bool> {
556 use ogeom_geom::Surface as _;
557
558 let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
559 .iter()
560 .map(Shape::node)
561 .collect();
562 let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
563 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
564 ogeom_bail!(Construction, "face node holds no face data");
565 };
566 let Some(surface) = model.geometry().surface(data.surface) else {
567 ogeom_bail!(Dangling, "face refers to a surface not in this model");
568 };
569 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
570 if projection.distance > tol.confusion() * 100.0 {
571 return Ok(None);
572 }
573 let (u, v) = projection.parameters;
574 let (du, dv) = surface.d1_at(u, v, tol)?;
575 let n = du.cross(dv);
576 let m = n.magnitude();
577 if m <= tol.confusion() {
578 return Ok(None);
579 }
580 Ok(Some(n / m))
581 };
582 for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
583 if other.node() == face.node() {
584 continue;
585 }
586 for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
587 if !own_edges.contains(&edge.node()) {
588 continue;
589 }
590 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
591 continue;
592 };
593 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
594 continue;
595 };
596 let Some(geometry) = model.geometry().curve(*curve) else {
597 continue;
598 };
599 let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
600 let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
601 else {
602 continue;
603 };
604 if a.cross(b).magnitude() <= 1e-6 {
605 return Ok(true);
606 }
607 }
608 }
609 Ok(false)
610}
611
612struct Prepared {
614 shape: Shape,
615 surface: SurfaceGeometry,
617 amount: f64,
619 sign: f64,
622 rings: Option<[Shape; 2]>,
624 whole_torus: bool,
626 fit_deviation: f64,
629}
630
631pub(crate) fn rebuilt(
652 model: &mut Model,
653 solid: &Shape,
654 amount_of: &dyn Fn(&Shape) -> f64,
655 instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
656 tol: Tolerances,
657) -> OgeomResult<Built> {
658 use ogeom_geom::Surface as _;
659 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
660
661 let span = ogeom_algo::shape_bounds(model, solid, tol)?.diagonal();
663 let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
664 for face in &faces {
665 let amount = amount_of(face);
666 let Some(node) = model.node(face) else {
667 ogeom_bail!(Dangling, "face is not in this model");
668 };
669 let NodeData::Face(data) = node.data() else {
670 ogeom_bail!(Construction, "face node holds no face data");
671 };
672 let Some(surface) = model.geometry().surface(data.surface) else {
673 ogeom_bail!(Dangling, "face refers to a surface not in this model");
674 };
675 let sign = if face.orientation() == Orientation::Reversed {
676 -1.0
677 } else {
678 1.0
679 };
680 let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
681 let mut counts: FastMap<TShapeId, usize> = FastMap::default();
682 for e in &edges {
683 *counts.entry(e.node()).or_insert(0) += 1;
684 }
685 let has_seam = counts.values().any(|c| *c >= 2);
686 let closed_rings: Vec<Shape> = edges
687 .iter()
688 .filter(|e| {
689 edge_vertices(model, e)
690 .ok()
691 .flatten()
692 .is_some_and(|(a, b)| a.node() == b.node())
693 })
694 .cloned()
695 .collect();
696
697 let whole_torus = matches!(surface, SurfaceGeometry::Torus(_))
700 && !counts.is_empty()
701 && counts.values().all(|c| *c == 2);
702 let both_poles = closed_rings.len() == 2
703 && closed_rings.iter().all(|ring| {
704 model
705 .node(ring)
706 .and_then(|n| n.data().as_edge())
707 .is_some_and(|d| d.degenerate)
708 });
709 let grow = amount.abs() * 4.0 + 1.0;
710 let mut fit_deviation = 0.0;
711 let replacement = instead_of(face);
712 let moved: SurfaceGeometry = if let Some(given) = replacement {
713 given
714 } else if amount == 0.0 {
715 match surface {
722 SurfaceGeometry::Plane(p) => {
723 let ((u0, u1), (v0, v1)) = surface.domain();
724 PlaneSurface::over(p.plane(), (u0 - span, u1 + span), (v0 - span, v1 + span))?
725 .into()
726 }
727 SurfaceGeometry::Cylinder(c) => {
728 let (_, (v0, v1)) = surface.domain();
729 CylinderSurface::new(c.cylinder(), (v0 - span, v1 + span))?.into()
730 }
731 SurfaceGeometry::Cone(co) => {
732 let (_, (v0, v1)) = surface.domain();
735 let apex = co.apex_height();
736 let (lo, hi) = if apex <= v0 {
737 ((v0 - span).max(apex), v1 + span)
738 } else {
739 (v0 - span, (v1 + span).min(apex))
740 };
741 ogeom_geom::ConeSurface::new(co.cone(), (lo, hi))?.into()
742 }
743 _ => surface.clone(),
744 }
745 } else {
746 match surface {
747 SurfaceGeometry::Plane(p) => {
748 let plane = p.plane();
749 let ((u0, u1), (v0, v1)) = surface.domain();
750 let shifted = Plane::new(Frame::new(
751 plane.origin() + plane.normal().vector() * (sign * amount),
752 plane.normal(),
753 plane.frame().x(),
754 tol,
755 )?);
756 PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
757 .into()
758 }
759 SurfaceGeometry::Cylinder(c) => {
760 let cylinder = c.cylinder();
761 let grown = sign.mul_add(amount, cylinder.radius());
762 if grown <= tol.confusion() {
763 ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
764 }
765 let (_, (v0, v1)) = surface.domain();
766 CylinderSurface::new(
767 Cylinder::new(cylinder.frame(), grown, tol)?,
768 (v0 - grow, v1 + grow),
769 )?
770 .into()
771 }
772 SurfaceGeometry::Sphere(sp) => {
773 let sphere = sp.sphere();
774 let grown = sign.mul_add(amount, sphere.radius());
775 if grown <= tol.confusion() {
776 ogeom_bail!(Construction, "the offset consumes the sphere's radius");
777 }
778 ogeom_geom::SphereSurface::new(ogeom_math::Sphere::new(
782 sphere.frame(),
783 grown,
784 tol,
785 )?)
786 .into()
787 }
788 SurfaceGeometry::Torus(t) => {
789 let torus = t.torus();
790 let grown = sign.mul_add(amount, torus.minor_radius());
791 if grown <= tol.confusion() {
792 ogeom_bail!(Construction, "the offset consumes the torus's tube");
793 }
794 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
795 torus.frame(),
796 torus.major_radius(),
797 grown,
798 tol,
799 )?)
800 .into()
801 }
802 SurfaceGeometry::Cone(co) => {
803 let cone = co.cone();
804 let grown = (sign * amount / cone.half_angle().cos())
807 .mul_add(1.0, cone.reference_radius());
808 if grown <= tol.confusion() {
809 ogeom_bail!(Construction, "the offset consumes the cone's throat");
810 }
811 let (_, (v0, v1)) = surface.domain();
812 ogeom_geom::ConeSurface::new(
813 ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
814 (v0 - grow, v1 + grow),
815 )?
816 .into()
817 }
818 free_form => {
819 let (fitted, deviation) = crate::freeform::moved_free_form(
820 model,
821 face,
822 free_form,
823 (sign, amount),
824 grow,
825 tol,
826 )?;
827 fit_deviation = deviation;
828 fitted
829 }
830 }
831 };
832 let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
836 prepared.push(Prepared {
837 shape: face.clone(),
838 surface: moved,
839 amount,
840 sign,
841 whole_torus,
842 fit_deviation,
843 rings: if has_seam && closed_rings.len() == 2 && !fitted_support && !both_poles {
847 Some([closed_rings[0].clone(), closed_rings[1].clone()])
848 } else {
849 None
850 },
851 });
852 }
853
854 let mut edge_faces: FastMap<TShapeId, Vec<usize>> = FastMap::default();
856 for (fi, face) in faces.iter().enumerate() {
857 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
858 let entry = edge_faces.entry(e.node()).or_default();
859 if !entry.contains(&fi) {
860 entry.push(fi);
861 }
862 }
863 }
864
865 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
869 let face = &faces[fi];
870 let Some(node) = model.node(face) else {
871 ogeom_bail!(Dangling, "face is not in this model");
872 };
873 let NodeData::Face(data) = node.data() else {
874 ogeom_bail!(Construction, "face node holds no face data");
875 };
876 let Some(surface) = model.geometry().surface(data.surface) else {
877 ogeom_bail!(Dangling, "face refers to a surface not in this model");
878 };
879 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
880 if projection.distance > tol.confusion() * 100.0 {
881 return Ok(None);
882 }
883 let (u, v) = projection.parameters;
884 let (du, dv) = surface.d1_at(u, v, tol)?;
885 let n = du.cross(dv);
886 let m = n.magnitude();
887 if m <= tol.confusion() {
888 if prepared[fi].amount == 0.0 || !is_free_form(surface) {
892 return Ok(None);
893 }
894 let amount = prepared[fi].amount;
895 let along = amount * prepared[fi].sign;
896 return Ok(crate::freeform::limit_normal(surface, u, v, along, tol)?
897 .map(|n| (n * prepared[fi].sign, amount)));
898 }
899 let outward = n / m * prepared[fi].sign;
900 Ok(Some((outward, prepared[fi].amount)))
901 };
902
903 let mut new_vertices: FastMap<TShapeId, (Shape, Point)> = FastMap::default();
907 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
908 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
909 continue;
910 };
911 let at = vertex.transform(model.datums())?.apply(data.point);
912 let mut seats: Vec<usize> = Vec::new();
913 for (fi, face) in faces.iter().enumerate() {
914 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
915 if v.node() == vertex.node() && !seats.contains(&fi) {
916 seats.push(fi);
917 }
918 }
919 }
920 if seats.is_empty() {
921 continue;
922 }
923 let mut normals: Vec<Vector> = Vec::new();
926 let mut amounts: Vec<f64> = Vec::new();
927 let mut kept: Vec<usize> = Vec::new();
928 for fi in &seats {
929 let Some((n, w)) = constraint(model, *fi, at)? else {
930 continue;
931 };
932 if let Some(k) = normals
933 .iter()
934 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
935 {
936 if (amounts[k] - w).abs() > tol.confusion() {
937 ogeom_bail!(
938 Construction,
939 "two tangent faces move a shared vertex by different \
940 amounts; the offset tears it"
941 );
942 }
943 continue;
944 }
945 normals.push(n);
946 amounts.push(w);
947 kept.push(*fi);
948 }
949 if normals.is_empty() {
950 let mut apex: Option<Point> = None;
954 for fi in &seats {
955 let Some(node) = model.node(&faces[*fi]) else {
956 continue;
957 };
958 let NodeData::Face(data) = node.data() else {
959 continue;
960 };
961 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
962 else {
963 continue;
964 };
965 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
966 continue;
967 }
968 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
969 apex = Some(moved_cone.cone().apex());
970 break;
971 }
972 }
973 if apex.is_none() {
978 for fi in &seats {
979 let Some(NodeData::Face(data)) = model.node(&faces[*fi]).map(|n| n.data())
980 else {
981 continue;
982 };
983 let Some(old @ SurfaceGeometry::Sphere(_)) =
984 model.geometry().surface(data.surface)
985 else {
986 continue;
987 };
988 if !matches!(prepared[*fi].surface, SurfaceGeometry::Sphere(_)) {
989 continue;
990 }
991 let local = faces[*fi].transform(model.datums())?;
992 let found =
993 ogeom_algo::project_on_surface(old, local.inverse()?.apply(at), 32, tol)?;
994 if found.distance > tol.confusion() * 100.0 {
995 continue;
996 }
997 let (u, v) = found.parameters;
998 apex = Some(local.apply(prepared[*fi].surface.point_at(u, v, tol)?));
999 break;
1000 }
1001 }
1002 let Some(moved) = apex else {
1003 ogeom_bail!(
1004 Construction,
1005 "a vertex with no seat the rebuild can read cannot be \
1006 re-solved"
1007 );
1008 };
1009 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1010 continue;
1011 }
1012 if normals.len() == 1 {
1013 let moved = at + normals[0] * amounts[0];
1018 corner_met(&prepared, &kept, moved, tol)?;
1019 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1020 continue;
1021 }
1022 if kept.len() == 2
1028 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
1029 {
1030 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1031 continue;
1032 }
1033 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
1034 for _ in 0..8 {
1038 let mut ns: Vec<Vector> = Vec::new();
1039 let mut rs: Vec<f64> = Vec::new();
1040 for fi in &kept {
1041 let projection =
1042 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
1043 let (u, v) = projection.parameters;
1044 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
1045 let n = du.cross(dv);
1046 let m = n.magnitude();
1047 if m <= tol.confusion() {
1048 continue;
1049 }
1050 let n = n / m;
1051 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
1052 ns.push(n);
1053 rs.push((moved - foot).dot(n));
1054 }
1055 if ns.len() < 2 {
1056 break;
1057 }
1058 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
1059 if worst <= tol.confusion() * 0.1 {
1060 break;
1061 }
1062 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
1063 moved += solve_corner(&ns, &step, tol)?;
1064 }
1065 corner_met(&prepared, &kept, moved, tol)?;
1066 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1067 }
1068
1069 let mut edge_uses: FastMap<TShapeId, usize> = FastMap::default();
1072 for face in &faces {
1073 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1074 *edge_uses.entry(e.node()).or_insert(0) += 1;
1075 }
1076 }
1077
1078 let mut new_edges: FastMap<TShapeId, Shape> = FastMap::default();
1080 let mut history = History::new();
1081 for edge in explore_unique(model, solid, ShapeType::Edge)? {
1082 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
1083 if sides.len() != 2 {
1084 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
1085 if let [fi] = sides.as_slice()
1090 && let Some(built) =
1091 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
1092 {
1093 history.modify(&edge, built.clone());
1094 new_edges.insert(edge.node(), built);
1095 }
1096 continue;
1097 }
1098 let Some(built) =
1101 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
1102 else {
1103 ogeom_bail!(
1104 Construction,
1105 "an edge with one face is neither a ring nor an apex; \
1106 the offset cannot re-derive it"
1107 );
1108 };
1109 history.modify(&edge, built.clone());
1110 new_edges.insert(edge.node(), built);
1111 continue;
1112 }
1113 let (curve, range) = {
1114 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1115 ogeom_bail!(Construction, "edge node holds no edge data");
1116 };
1117 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1118 ogeom_bail!(Construction, "an edge has no curve to offset");
1119 };
1120 let Some(geometry) = model.geometry().curve(*curve) else {
1121 ogeom_bail!(Dangling, "curve is not in this model");
1122 };
1123 (geometry.clone(), *range)
1124 };
1125 let forward = if edge.orientation() == Orientation::Reversed {
1126 edge.reversed()
1127 } else {
1128 edge.clone()
1129 };
1130 let built = match &curve {
1131 Curve::Line(_) => {
1132 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1139 ogeom_bail!(Construction, "a straight edge has no vertices");
1140 };
1141 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1142 new_vertices.get(&sv.node()).cloned(),
1143 new_vertices.get(&ev.node()).cloned(),
1144 ) else {
1145 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1146 };
1147 if p_to.distance(p_from) <= tol.parametric() {
1148 ogeom_bail!(Construction, "the offset collapses an edge");
1149 }
1150 let segment = LineCurve::segment(p_from, p_to, tol)?;
1151 let (t0, t1) = segment.domain();
1152 let moved: Curve = segment.into();
1153 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1154 }
1155 Curve::Circle(c)
1156 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
1157 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
1158 {
1159 let circle = c.circle();
1164 let found = ogeom_intersect::intersect_surfaces(
1165 &prepared[sides[0]].surface,
1166 &prepared[sides[1]].surface,
1167 ogeom_intersect::IntersectOptions::default(),
1168 tol,
1169 )?;
1170 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1171 ogeom_bail!(
1172 Construction,
1173 "the moved faces no longer meet along the edge they \
1174 shared; the offset collapses it"
1175 );
1176 };
1177 let mut best: Option<(ogeom_math::Circle, f64)> = None;
1178 for section in &candidates {
1179 let Curve::Circle(cc) = §ion.curve else {
1180 continue;
1181 };
1182 let candidate = cc.circle();
1183 let score = candidate.centre().distance(circle.centre())
1184 + (candidate.radius() - circle.radius()).abs();
1185 if best.as_ref().is_none_or(|(_, held)| score < *held) {
1186 best = Some((candidate, score));
1187 }
1188 }
1189 let Some((candidate, _)) = best else {
1190 ogeom_bail!(
1191 Construction,
1192 "the moved faces meet along nothing circular where a \
1193 circle was; the offset needs the general rebuild"
1194 );
1195 };
1196 let reframed = ogeom_math::Circle::new(
1197 Frame::new(
1198 candidate.centre(),
1199 circle.frame().z(),
1200 circle.frame().x(),
1201 tol,
1202 )?,
1203 candidate.radius(),
1204 tol,
1205 )?;
1206 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1207 let closed = {
1208 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1209 ogeom_bail!(Construction, "a ring has no vertex");
1210 };
1211 sv.node() == ev.node()
1212 };
1213 if closed {
1214 make_edge(model, moved, range, tol)?.shape
1215 } else {
1216 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1217 ogeom_bail!(Construction, "an arc has no vertices");
1218 };
1219 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1220 new_vertices.get(&sv.node()).cloned(),
1221 new_vertices.get(&ev.node()).cloned(),
1222 ) else {
1223 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
1224 };
1225 let angle_of = |p: Point| {
1226 let l = reframed.frame().to_local(p);
1227 l.y.atan2(l.x)
1228 };
1229 let tau = core::f64::consts::TAU;
1230 let mut t0 = angle_of(p_from);
1231 let mut t1 = angle_of(p_to);
1232 while t0 < range.0 - core::f64::consts::PI {
1234 t0 += tau;
1235 }
1236 while t0 > range.0 + core::f64::consts::PI {
1237 t0 -= tau;
1238 }
1239 while t1 <= t0 + tol.parametric() {
1240 t1 += tau;
1241 }
1242 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
1243 t1 -= tau;
1244 }
1245 if t1 <= t0 + tol.parametric() {
1246 ogeom_bail!(Construction, "the offset collapses an arc");
1247 }
1248 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1249 }
1250 }
1251 _ => {
1252 let unmoved = {
1257 let mut worst = 0.0_f64;
1258 'probe: for i in 0..9 {
1259 #[allow(clippy::cast_precision_loss)]
1260 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
1261 let p = curve.point_at(t, tol)?;
1262 for side in [sides[0], sides[1]] {
1263 let Ok(near) =
1264 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
1265 else {
1266 worst = f64::INFINITY;
1267 break 'probe;
1268 };
1269 worst = worst.max(near.distance);
1270 }
1271 }
1272 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
1276 };
1277 if let Some(worst) = unmoved {
1278 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1279 ogeom_bail!(Construction, "an edge has no vertices");
1280 };
1281 let closed = sv.node() == ev.node();
1282 let built = if closed {
1283 match new_vertices.get(&sv.node()).cloned() {
1286 Some((v_at, p_at)) => {
1287 let gap = curve.point_at(range.0, tol)?.distance(p_at);
1288 if gap > tol.confusion() {
1289 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1290 }
1291 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
1292 .shape
1293 }
1294 None => make_edge(model, curve.clone(), range, tol)?.shape,
1295 }
1296 } else {
1297 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1298 new_vertices.get(&sv.node()).cloned(),
1299 new_vertices.get(&ev.node()).cloned(),
1300 ) else {
1301 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1302 };
1303 let gap = curve
1307 .point_at(range.0, tol)?
1308 .distance(p_from)
1309 .min(curve.point_at(range.0, tol)?.distance(p_to))
1310 .max(
1311 curve
1312 .point_at(range.1, tol)?
1313 .distance(p_to)
1314 .min(curve.point_at(range.1, tol)?.distance(p_from)),
1315 );
1316 if gap > tol.confusion() {
1317 for v in [&v_from, &v_to] {
1318 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1319 }
1320 }
1321 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
1322 };
1323 if worst > tol.confusion() {
1324 model.widen(&built, ogeom_core::Tolerance::new(worst)?)?;
1325 }
1326 history.modify(&edge, built.clone());
1327 new_edges.insert(edge.node(), built);
1328 continue;
1329 }
1330 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1337 let found = ogeom_intersect::intersect_surfaces(
1338 &prepared[sides[0]].surface,
1339 &prepared[sides[1]].surface,
1340 ogeom_intersect::IntersectOptions::default(),
1341 tol,
1342 )?;
1343 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1344 ogeom_bail!(
1345 Construction,
1346 "the moved faces no longer meet along the edge they \
1347 shared; the offset collapses it"
1348 );
1349 };
1350 let mut best: Option<(Curve, f64, f64)> = None;
1351 for section in candidates {
1352 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
1353 else {
1354 continue;
1355 };
1356 if best
1357 .as_ref()
1358 .is_none_or(|(_, _, held)| projected.distance < *held)
1359 {
1360 best = Some((section.curve, section.tolerance, projected.distance));
1361 }
1362 }
1363 let Some((moved, slop, _)) = best else {
1364 ogeom_bail!(
1365 Construction,
1366 "the moved faces meet along nothing where the edge \
1367 was; the offset collapses it"
1368 );
1369 };
1370 let closed = {
1371 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1372 ogeom_bail!(Construction, "an edge has no vertices");
1373 };
1374 sv.node() == ev.node()
1375 };
1376 let built = if closed {
1377 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1383 ogeom_bail!(Construction, "a ring has no vertex");
1384 };
1385 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1386 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1387 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1388 let (lo, hi) = moved.domain();
1389 let seamed: Curve = if t.parameter > lo + tol.parametric()
1390 && t.parameter < hi - tol.parametric()
1391 {
1392 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1393 } else {
1394 moved.clone()
1395 };
1396 let seamed = {
1400 use ogeom_geom::Reversible as _;
1401 let (a, _) = seamed.domain();
1402 let old = curve.d1_at(range.0, tol)?;
1403 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1404 seamed.reversed()
1405 } else {
1406 seamed
1407 }
1408 };
1409 let miss = t.distance.max(slop);
1410 if miss > tol.confusion() {
1411 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1412 }
1413 let window = seamed.domain();
1414 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1415 }
1416 _ => {
1417 let window = moved.domain();
1418 make_edge(model, moved, window, tol)?.shape
1419 }
1420 }
1421 } else {
1422 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1423 ogeom_bail!(Construction, "an edge has no vertices");
1424 };
1425 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1426 new_vertices.get(&sv.node()).cloned(),
1427 new_vertices.get(&ev.node()).cloned(),
1428 ) else {
1429 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1430 };
1431 if slop > tol.confusion() {
1434 for v in [&v_from, &v_to] {
1435 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1436 }
1437 }
1438 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1439 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1440 if (tb - ta).abs() <= tol.parametric() {
1441 ogeom_bail!(Construction, "the offset collapses an edge");
1442 }
1443 let (moved, ta, tb) = if moved.is_periodic() {
1451 let along = moved.d1_at(ta, tol)?.dot(curve.d1_at(range.0, tol)?) >= 0.0;
1452 let (moved, ta, tb) = if along {
1453 (moved, ta, tb)
1454 } else {
1455 use ogeom_geom::Reversible as _;
1456 let (lo, hi) = moved.domain();
1457 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1458 };
1459 let (lo, hi) = moved.domain();
1460 let tb = ta + (tb - ta).rem_euclid(hi - lo);
1461 if tb - ta <= tol.parametric() {
1462 ogeom_bail!(Construction, "the offset collapses an edge");
1463 }
1464 (moved, ta, tb)
1465 } else {
1466 let (lo, hi) = moved.domain();
1473 let (ta, tb) = if moved.is_closed(tol) {
1474 let along =
1475 moved.d1_at(ta, tol)?.dot(curve.d1_at(range.0, tol)?) >= 0.0;
1476 let at = |t: f64, end: f64| (t - end).abs() <= tol.parametric();
1477 let (start, finish) = if along { (lo, hi) } else { (hi, lo) };
1478 (
1479 if at(ta, finish) { start } else { ta },
1480 if at(tb, start) { finish } else { tb },
1481 )
1482 } else {
1483 (ta, tb)
1484 };
1485 if (tb - ta).abs() <= tol.parametric() {
1486 ogeom_bail!(Construction, "the offset collapses an edge");
1487 }
1488 if ta <= tb {
1489 (moved, ta, tb)
1490 } else {
1491 use ogeom_geom::Reversible as _;
1492 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1493 }
1494 };
1495 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1496 };
1497 if slop > tol.confusion() {
1498 model.widen(&built, ogeom_core::Tolerance::new(slop)?)?;
1499 }
1500 built
1501 }
1502 };
1503 history.modify(&edge, built.clone());
1504 new_edges.insert(edge.node(), built);
1505 }
1506
1507 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1510 for prep in &prepared {
1511 let built = if let (true, SurfaceGeometry::Torus(t)) = (prep.whole_torus, &prep.surface) {
1512 let torus = t.torus();
1516 let face = ogeom_algo::torus_face_seamed_at(
1517 model,
1518 torus.frame(),
1519 torus.major_radius(),
1520 torus.minor_radius(),
1521 0.0,
1522 tol,
1523 )?;
1524 if prep.shape.orientation() == Orientation::Reversed {
1525 face.reversed()
1526 } else {
1527 face
1528 }
1529 } else if let Some(rings) = &prep.rings {
1530 let (Some(lo), Some(hi)) = (
1531 new_edges.get(&rings[0].node()),
1532 new_edges.get(&rings[1].node()),
1533 ) else {
1534 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1535 };
1536 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1537 if prep.shape.orientation() == Orientation::Reversed {
1538 band.reversed()
1539 } else {
1540 band
1541 }
1542 } else {
1543 let mut wires: Vec<Vec<Shape>> = Vec::new();
1544 let mut face_uses: FastMap<TShapeId, usize> = FastMap::default();
1545 let stored = prep.shape.oriented(Orientation::Forward);
1548 for wire in explore(model, &stored, Filter::OfType(ShapeType::Wire))? {
1549 let mut edges: Vec<Shape> = Vec::new();
1550 for used in model.ordered_children_of(&wire)? {
1553 *face_uses.entry(used.node()).or_insert(0) += 1;
1554 let Some(fresh) = new_edges.get(&used.node()) else {
1555 ogeom_bail!(Construction, "a face edge was not rebuilt");
1556 };
1557 edges.push(if used.orientation() == Orientation::Reversed {
1558 fresh.reversed()
1559 } else {
1560 fresh.clone()
1561 });
1562 }
1563 wires.push(edges);
1564 }
1565 let face = if face_uses.values().any(|c| *c >= 2) {
1566 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1571 } else {
1572 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1573 };
1574 if prep.shape.orientation() == Orientation::Reversed {
1575 face.reversed()
1576 } else {
1577 face
1578 }
1579 };
1580 if let Some(why) = ogeom_algo::chart_breaks(model, &built, &|_| false, tol)?.first() {
1584 ogeom_bail!(
1585 Construction,
1586 "the offset cannot close a face it rebuilt: {why}"
1587 );
1588 }
1589 if prep.fit_deviation > tol.confusion() {
1590 model.widen(&built, ogeom_core::Tolerance::new(prep.fit_deviation)?)?;
1591 }
1592 history.modify(&prep.shape, built.clone());
1593 rebuilt_faces.push(built);
1594 }
1595
1596 let mut shells = Vec::new();
1602 let mut taken = 0;
1603 for old in model.children_of(solid)? {
1604 let count = explore(model, &old, Filter::OfType(ShapeType::Face))?.len();
1605 let Some(own) = rebuilt_faces.get(taken..taken + count) else {
1606 ogeom_bail!(Construction, "the offset solid did not close");
1607 };
1608 taken += count;
1609 let sewn = sew(model, own, tol)?;
1610 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1611 ogeom_bail!(Construction, "the offset solid did not close");
1612 }
1613 shells.push(if old.orientation() == Orientation::Reversed {
1614 sewn.shells[0].reversed()
1615 } else {
1616 sewn.shells[0].clone()
1617 });
1618 }
1619 if shells.is_empty() || taken != rebuilt_faces.len() {
1620 ogeom_bail!(Construction, "the offset solid did not close");
1621 }
1622 let offset = model.add_solid(&shells)?;
1625
1626 let size = ogeom_algo::shape_bounds(model, &offset, tol)?.diagonal();
1637 let fine = (size * 1e-5).clamp(
1638 tol.confusion() * 1e2,
1639 ogeom_mesh::Deflection::default().chord,
1640 );
1641 let mut mass = None;
1642 let mut first_error = None;
1643 for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1644 let deflection = ogeom_mesh::Deflection {
1645 chord,
1646 ..ogeom_mesh::Deflection::default()
1647 };
1648 match ogeom_algo::volume_properties(model, &offset, deflection, tol) {
1649 Ok(props) => {
1650 mass = Some(props.mass);
1651 break;
1652 }
1653 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1654 return Err(e);
1655 }
1656 Err(e) => {
1657 first_error.get_or_insert(e);
1658 }
1659 }
1660 }
1661 let Some(mass) = mass else {
1662 ogeom_bail!(
1663 Construction,
1664 "the offset solid's mesh does not close at any tried resolution{}",
1665 first_error.map_or_else(String::new, |e| format!(": {e}"))
1666 );
1667 };
1668 if !mass.is_finite() || mass <= tol.confusion() {
1669 ogeom_bail!(Construction, "the offset collapses the solid");
1670 }
1671
1672 history.modify(solid, offset.clone());
1673 Ok(Built::new(offset, history))
1674}
1675
1676fn rebuilt_seam_edge(
1685 model: &mut Model,
1686 edge: &Shape,
1687 prep: &Prepared,
1688 new_vertices: &FastMap<TShapeId, (Shape, Point)>,
1689 tol: Tolerances,
1690) -> OgeomResult<Option<Shape>> {
1691 use ogeom_geom::Curve2d as _;
1692
1693 let old_surface = {
1694 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1695 else {
1696 ogeom_bail!(Construction, "face node holds no face data");
1697 };
1698 data.surface
1699 };
1700 let found = {
1701 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1702 ogeom_bail!(Construction, "edge node holds no edge data");
1703 };
1704 let mut found = None;
1705 for repr in &data.representations {
1706 if let EdgeRepr::Seam {
1707 forward,
1708 surface,
1709 range,
1710 ..
1711 } = repr
1712 && *surface == old_surface
1713 {
1714 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1715 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1716 };
1717 let (from, to) = (
1723 pcurve.point_at(range.0, tol)?,
1724 pcurve.point_at(range.1, tol)?,
1725 );
1726 if (to.x - from.x).abs() >= (to.y - from.y).abs() {
1729 return Ok(None);
1730 }
1731 found = Some((from.x, (from.y, to.y)));
1732 break;
1733 }
1734 }
1735 found
1736 };
1737 let Some((column, rows)) = found else {
1738 return Ok(None);
1739 };
1740 let forward = if edge.orientation() == Orientation::Reversed {
1744 edge.reversed()
1745 } else {
1746 edge.clone()
1747 };
1748 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1749 ogeom_bail!(Construction, "a seam has no vertices");
1750 };
1751 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1752 new_vertices.get(&sv.node()).cloned(),
1753 new_vertices.get(&ev.node()).cloned(),
1754 ) else {
1755 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1756 };
1757 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1758 ogeom_bail!(
1759 Construction,
1760 "the moved surface's iso-curve has no closed form; no seam can \
1761 be rebuilt"
1762 );
1763 };
1764 let along = |p: Point| -> OgeomResult<f64> {
1768 match &curve {
1769 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1770 Curve::Circle(c) => {
1771 let local = c.circle().frame().to_local(p);
1772 let mut angle = local.y.atan2(local.x);
1773 if angle < 0.0 {
1774 angle += core::f64::consts::TAU;
1775 }
1776 Ok(angle)
1777 }
1778 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1782 }
1783 };
1784 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1785 let (t_start, t_end) = if matches!(curve, Curve::Circle(_)) {
1792 let tau = core::f64::consts::TAU;
1793 let start = rows.0 + (t_start - rows.0 + core::f64::consts::PI).rem_euclid(tau)
1794 - core::f64::consts::PI;
1795 let sweep = match (sv.node() == ev.node(), rows.1 >= rows.0) {
1796 (true, true) => tau,
1797 (true, false) => -tau,
1798 (false, true) => (t_end - t_start).rem_euclid(tau),
1799 (false, false) => (t_end - t_start).rem_euclid(tau) - tau,
1800 };
1801 (start, start + sweep)
1802 } else {
1803 (t_start, t_end)
1804 };
1805 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1815 (tol.confusion() * 1e3).max(1e-4) * 4.0
1816 } else {
1817 tol.confusion() * 100.0
1818 };
1819 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1820 let off = curve.point_at(t, tol)?.distance(p);
1821 if off > slack {
1822 return Ok(None);
1823 }
1824 if off > tol.confusion() {
1827 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1828 }
1829 }
1830 Ok(Some(if t_start <= t_end {
1831 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1832 } else {
1833 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1837 .shape
1838 .reversed()
1839 }))
1840}
1841
1842fn assembled_with_seam(
1850 model: &mut Model,
1851 prep: &Prepared,
1852 wires: &[Vec<Shape>],
1853 new_edges: &FastMap<TShapeId, Shape>,
1854 tol: Tolerances,
1855) -> OgeomResult<Shape> {
1856 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1857 for edges in wires {
1858 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1859 }
1860 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1861 let new_surface = {
1862 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1863 ogeom_bail!(Construction, "the face just built holds no face data");
1864 };
1865 data.surface
1866 };
1867 let old_surface = {
1868 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1869 else {
1870 ogeom_bail!(Construction, "face node holds no face data");
1871 };
1872 data.surface
1873 };
1874
1875 let mut done: Vec<TShapeId> = Vec::new();
1876 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1877 if done.contains(&used.node()) {
1878 continue;
1879 }
1880 done.push(used.node());
1881 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1882 ogeom_bail!(Construction, "a face edge was not rebuilt");
1883 };
1884 if model
1887 .node(&fresh)
1888 .and_then(|n| n.data().as_edge())
1889 .is_some_and(|d| d.degenerate)
1890 {
1891 let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1892 .node(&used)
1893 .and_then(|n| n.data().as_edge())
1894 .and_then(|d| d.pcurve_for(old_surface, used.location()))
1895 .cloned()
1896 else {
1897 ogeom_bail!(Construction, "a pole has no row on its face");
1898 };
1899 let Some(row) = model.geometry().pcurve(curve).cloned() else {
1900 ogeom_bail!(Dangling, "a pole's row is not in this model");
1901 };
1902 ogeom_algo::attach_pcurve(
1903 model,
1904 &fresh,
1905 row,
1906 new_surface,
1907 ogeom_topo::Location::identity(),
1908 range,
1909 )?;
1910 continue;
1911 }
1912 let (fresh_curve, fresh_range) = {
1913 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1914 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1915 };
1916 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1917 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1918 };
1919 let Some(geometry) = model.geometry().curve(*curve) else {
1920 ogeom_bail!(Dangling, "curve is not in this model");
1921 };
1922 (geometry.clone(), *range)
1923 };
1924 let columns = {
1925 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1926 ogeom_bail!(Construction, "edge node holds no edge data");
1927 };
1928 let mut columns = None;
1929 for repr in &data.representations {
1930 if let EdgeRepr::Seam {
1931 forward,
1932 reversed,
1933 surface,
1934 range,
1935 ..
1936 } = repr
1937 && *surface == old_surface
1938 {
1939 use ogeom_geom::Curve2d as _;
1940 let (Some(f), Some(r)) = (
1941 model.geometry().pcurve(*forward),
1942 model.geometry().pcurve(*reversed),
1943 ) else {
1944 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1945 };
1946 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1947 break;
1948 }
1949 }
1950 columns
1951 };
1952 if let Some((forward_col, reversed_col)) = columns {
1953 let rows = match &prep.surface {
1956 SurfaceGeometry::Cone(c) => {
1957 let cos = c.cone().half_angle().cos();
1958 (fresh_range.0 * cos, fresh_range.1 * cos)
1959 }
1960 _ => fresh_range,
1961 };
1962 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1963 Ok(ogeom_geom::Line2d::over(
1964 ogeom_math::Axis2::new(
1965 ogeom_math::Point2::new(u, 0.0),
1966 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1967 ),
1968 rows.0 - 1.0,
1969 rows.1 + 1.0,
1970 )?
1971 .into())
1972 };
1973 ogeom_algo::attach_seam(
1974 model,
1975 &fresh,
1976 column(forward_col)?,
1977 column(reversed_col)?,
1978 new_surface,
1979 ogeom_topo::Location::identity(),
1980 rows,
1981 )?;
1982 } else {
1983 let pcurve = match ogeom_intersect::exact_pcurve_over(
1987 &fresh_curve,
1988 fresh_range,
1989 &prep.surface,
1990 tol,
1991 ) {
1992 Some(exact) => exact,
1993 None => {
1994 let (fitted, _, _, worst_off, _) =
1995 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1996 &fresh_curve,
1997 fresh_range,
1998 &prep.surface,
1999 tol,
2000 )?;
2001 if worst_off > tol.confusion() {
2002 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
2006 model.widen(&fresh, widened)?;
2007 if let Some((a, b)) = edge_vertices(model, &fresh)? {
2008 model.widen(&a, widened)?;
2009 model.widen(&b, widened)?;
2010 }
2011 }
2012 fitted
2013 }
2014 };
2015 ogeom_algo::attach_pcurve(
2016 model,
2017 &fresh,
2018 pcurve,
2019 new_surface,
2020 ogeom_topo::Location::identity(),
2021 fresh_range,
2022 )?;
2023 if let Some(gap) = ogeom_algo::edge_pcurve_gap(model, &fresh, tol)?
2029 && model
2030 .node(&fresh)
2031 .and_then(|n| n.data().as_edge())
2032 .is_some_and(|d| gap > d.tolerance.get())
2033 {
2034 let widened = ogeom_core::Tolerance::new(gap * (1.0 + 1e-6))?;
2035 model.widen(&fresh, widened)?;
2036 if let Some((a, b)) = edge_vertices(model, &fresh)? {
2037 model.widen(&a, widened)?;
2038 model.widen(&b, widened)?;
2039 }
2040 }
2041 }
2042 }
2043 Ok(face)
2044}
2045
2046fn seam_end(
2051 model: &Model,
2052 faces: &[Shape],
2053 prepared: &[Prepared],
2054 vertex: &Shape,
2055 kept: &[usize],
2056 at: Point,
2057 tol: Tolerances,
2058) -> OgeomResult<Option<Point>> {
2059 use ogeom_geom::Curve2d as _;
2060 for (slot, &fi) in kept.iter().enumerate() {
2061 let other = kept[1 - slot];
2062 let face = &faces[fi];
2063 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
2064 continue;
2065 };
2066 let old_surface = data.surface;
2067 let mut uses: FastMap<TShapeId, usize> = FastMap::default();
2068 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
2069 *uses.entry(e.node()).or_insert(0) += 1;
2070 }
2071 for e in explore_unique(model, face, ShapeType::Edge)? {
2072 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
2073 continue;
2074 }
2075 let Some((a, b)) = edge_vertices(model, &e)? else {
2076 continue;
2077 };
2078 if a.node() != vertex.node() && b.node() != vertex.node() {
2079 continue;
2080 }
2081 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
2082 continue;
2083 };
2084 let mut column = None;
2085 for repr in &edata.representations {
2086 if let EdgeRepr::Seam {
2087 forward,
2088 surface,
2089 range,
2090 ..
2091 } = repr
2092 && *surface == old_surface
2093 && let Some(pcurve) = model.geometry().pcurve(*forward)
2094 {
2095 column = Some(pcurve.point_at(range.0, tol)?.x);
2096 break;
2097 }
2098 }
2099 let Some(column) = column else {
2100 continue;
2101 };
2102 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
2103 else {
2104 continue;
2105 };
2106 let found = ogeom_intersect::intersect_curve_surface(
2107 &iso,
2108 &prepared[other].surface,
2109 ogeom_intersect::CurveSurfaceOptions::default(),
2110 tol,
2111 )?;
2112 let nearest = found
2113 .crossings
2114 .iter()
2115 .map(|hit| hit.point)
2116 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
2117 if let Some(p) = nearest {
2118 return Ok(Some(p));
2119 }
2120 }
2121 }
2122 Ok(None)
2123}
2124
2125fn rebuilt_lone_edge(
2132 model: &mut Model,
2133 edge: &Shape,
2134 sides: &[usize],
2135 constraint: &Displacement<'_>,
2136 new_vertices: &FastMap<TShapeId, (Shape, Point)>,
2137 tol: Tolerances,
2138) -> OgeomResult<Option<Shape>> {
2139 use ogeom_geom::Curve3d as _;
2140
2141 let (degenerate, curve) = {
2142 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
2143 ogeom_bail!(Construction, "edge node holds no edge data");
2144 };
2145 let curve = data.curve3d().and_then(|repr| {
2146 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
2147 return None;
2148 };
2149 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
2150 });
2151 (data.degenerate, curve)
2152 };
2153 let Some((start, end)) = edge_vertices(model, edge)? else {
2154 ogeom_bail!(Construction, "a lone edge has no vertices");
2155 };
2156 if degenerate {
2157 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
2159 ogeom_bail!(Construction, "an apex has no re-solved vertex");
2160 };
2161 let mut data = EdgeData::new();
2162 data.degenerate = true;
2163 return Ok(Some(
2164 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
2165 ));
2166 }
2167 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
2168 else {
2169 return Ok(None);
2170 };
2171 let circle = c.circle();
2172 let mut moved_points = Vec::with_capacity(3);
2173 for k in 0..3 {
2174 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
2175 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
2176 let p = Curve::Circle(c).point_at(t, tol)?;
2177 let Some((n, w)) = constraint(model, fi, p)? else {
2178 return Ok(None);
2179 };
2180 moved_points.push(p + n * w);
2181 }
2182 let centre = Point::from_vector(
2186 moved_points
2187 .iter()
2188 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
2189 / 3.0,
2190 );
2191 let radius = centre.distance(moved_points[0]);
2192 let reframed = ogeom_math::Circle::new(
2193 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
2194 radius,
2195 tol,
2196 )?;
2197 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
2198 Ok(Some(make_edge(model, moved, range, tol)?.shape))
2199}
2200
2201fn corner_met(
2209 prepared: &[Prepared],
2210 seats: &[usize],
2211 at: Point,
2212 tol: Tolerances,
2213) -> OgeomResult<()> {
2214 for fi in seats {
2215 let off = match &prepared[*fi].surface {
2219 SurfaceGeometry::Plane(s) => s.plane().distance_to(at),
2220 SurfaceGeometry::Cylinder(s) => s.cylinder().distance_to(at),
2221 SurfaceGeometry::Cone(s) => s.cone().distance_to(at),
2222 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(at),
2223 SurfaceGeometry::Torus(s) => s.torus().distance_to(at),
2224 other => ogeom_algo::project_on_surface(other, at, 32, tol)?.distance,
2225 };
2226 if off > (tol.confusion() * 1e3).max(1e-4) {
2227 ogeom_bail!(
2228 Construction,
2229 "the moved faces no longer meet at a corner they shared; the \
2230 edit pulls them apart there"
2231 );
2232 }
2233 }
2234 Ok(())
2235}
2236
2237fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
2238 let mut a = [[0.0_f64; 3]; 3];
2240 let mut b = [0.0_f64; 3];
2241 for (n, w) in normals.iter().zip(amounts) {
2242 let row = [n.x, n.y, n.z];
2243 for i in 0..3 {
2244 for j in 0..3 {
2245 a[i][j] += row[i] * row[j];
2246 }
2247 b[i] += row[i] * w;
2248 }
2249 }
2250 if normals.len() == 2 {
2254 let along = normals[0].cross(normals[1]);
2255 let m = along.magnitude();
2256 if m <= tol.angular() {
2257 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2258 }
2259 let d = along / m;
2260 let row = [d.x, d.y, d.z];
2261 for i in 0..3 {
2262 for j in 0..3 {
2263 a[i][j] += row[i] * row[j];
2264 }
2265 }
2266 }
2267 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2268 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2269 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2270 if det.abs() <= tol.angular() * tol.angular() {
2271 ogeom_bail!(
2272 Construction,
2273 "a corner's faces are too nearly parallel to re-solve"
2274 );
2275 }
2276 let inv = |r: usize, c: usize| -> f64 {
2277 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2278 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2279 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2280 };
2281 let mut x = [0.0_f64; 3];
2282 for (i, xi) in x.iter_mut().enumerate() {
2283 for (j, bj) in b.iter().enumerate() {
2284 *xi += inv(i, j) * bj;
2285 }
2286 }
2287 Ok(Vector::new(x[0], x[1], x[2]))
2288}
2289
2290fn growing_edges(
2295 model: &Model,
2296 body: &Shape,
2297 held: &[TShapeId],
2298 convex: bool,
2299 tol: Tolerances,
2300) -> OgeomResult<Vec<Shape>> {
2301 use ogeom_geom::{Curve3d as _, Surface as _};
2302 let mut holders: ogeom_core::FastMap<TShapeId, Vec<(Shape, Shape)>> =
2305 ogeom_core::FastMap::default();
2306 for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2307 for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2308 holders.entry(e.node()).or_default().push((e, face.clone()));
2309 }
2310 }
2311 let mut out = Vec::new();
2312 for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2313 let mut faces: Vec<Shape> = Vec::new();
2314 for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2315 if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2316 faces.push(face.clone());
2317 }
2318 }
2319 let mut distinct: Vec<Shape> = Vec::new();
2320 for f in faces {
2321 if !distinct.iter().any(|d| d.node() == f.node()) {
2322 distinct.push(f);
2323 }
2324 }
2325 let [f1, f2] = distinct.as_slice() else {
2326 continue;
2327 };
2328 if held.contains(&f1.node()) || held.contains(&f2.node()) {
2329 continue;
2330 }
2331 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2332 continue;
2333 };
2334 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2335 continue;
2336 };
2337 let Some(geometry) = model.geometry().curve(*curve) else {
2338 continue;
2339 };
2340 let p = edge
2341 .transform(model.datums())?
2342 .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2343 let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2345 continue;
2346 };
2347 let Some(surface) = model.geometry().surface(face_data.surface) else {
2348 continue;
2349 };
2350 use ogeom_geom::Transformable as _;
2351 let placed = surface
2352 .clone()
2353 .transformed(&f1.transform(model.datums())?, tol)?;
2354 let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2355 let mut n1 = placed.normal_at(u, v, tol)?.vector();
2356 if f1.orientation() == Orientation::Reversed {
2357 n1 = -n1;
2358 }
2359 let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2362 let below = mesh
2363 .positions
2364 .iter()
2365 .map(|q| (*q - p).dot(n1))
2366 .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2367 .unwrap_or(0.0);
2368 if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2369 out.push(edge);
2370 }
2371 }
2372 Ok(out)
2373}