1use crate::wire2d::Join;
30use ogeom_algo::{
31 Built, History, edge_vertices, make_edge, make_edge_between, make_face_with_pcurves,
32 make_revolution_band, make_solid, make_vertex, sew,
33};
34use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
35use ogeom_geom::Curve3d as _;
36use ogeom_geom::{Curve, CylinderSurface, LineCurve, PlaneSurface, SurfaceGeometry};
37use ogeom_math::{Cylinder, Frame, Plane, Point, Vector};
38use ogeom_topo::{
39 EdgeData, EdgeRepr, Filter, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
40 explore_unique,
41};
42
43use std::collections::HashMap;
44
45type Displacement<'a> = dyn Fn(&Model, usize, Point) -> OgeomResult<Option<(Vector, f64)>> + 'a;
47
48pub(crate) fn canonical_input(
60 model: &mut Model,
61 solid: &Shape,
62 handles: &[Shape],
63 tol: Tolerances,
64) -> OgeomResult<(Shape, Vec<Shape>, Option<ogeom_algo::History>)> {
65 let probe = Point::new(0.123_456_789, 9.87, -3.21);
66 let mut seen: HashMap<TShapeId, Point> = HashMap::new();
67 let mut instanced = false;
68 'outer: for kind in [ShapeType::Vertex, ShapeType::Edge, ShapeType::Face] {
69 for occurrence in explore(model, solid, Filter::OfType(kind))? {
70 let at = occurrence.transform(model.datums())?.apply(probe);
71 if at.distance(probe) > tol.confusion() {
72 instanced = true;
73 break 'outer;
74 }
75 match seen.entry(occurrence.node()) {
76 std::collections::hash_map::Entry::Occupied(held) => {
77 if held.get().distance(at) > tol.confusion() {
78 instanced = true;
79 break 'outer;
80 }
81 }
82 std::collections::hash_map::Entry::Vacant(slot) => {
83 slot.insert(at);
84 }
85 }
86 }
87 }
88 if !instanced {
89 return Ok((solid.clone(), handles.to_vec(), None));
90 }
91 let baked = ogeom_algo::baked_shape(model, solid, tol)?;
92 let mapped = handles
93 .iter()
94 .map(|h| match baked.history.trace(h) {
95 [one] => Ok(one.clone()),
96 traced => ogeom_bail!(
97 Construction,
98 "a face handle resolved to {} faces through the canonical \
99 rebuild; the reference is ambiguous",
100 traced.len()
101 ),
102 })
103 .collect::<OgeomResult<Vec<Shape>>>()?;
104 Ok((baked.shape, mapped, Some(baked.history)))
105}
106
107pub fn offset_shape(
116 model: &mut Model,
117 solid: &Shape,
118 offset: f64,
119 tol: Tolerances,
120) -> OgeomResult<Built> {
121 if !offset.is_finite() || offset.abs() <= tol.confusion() {
122 ogeom_bail!(Construction, "an offset of {offset} moves nothing");
123 }
124 let (canonical, _, prefix) = canonical_input(model, solid, &[], tol)?;
125 if let Some(prefix) = prefix {
126 let mut out = offset_shape(model, &canonical, offset, tol)?;
127 out.history = prefix.then(&out.history);
128 return Ok(out);
129 }
130 let built = rebuilt(model, solid, &|_| offset, &|_| None, tol)?;
131 right_side_out(model, built, tol)
132}
133
134fn right_side_out(model: &Model, built: Built, tol: Tolerances) -> OgeomResult<Built> {
140 if !ogeom_algo::inside_out_faces(model, &built.shape, tol)?.is_empty() {
141 ogeom_bail!(
142 Construction,
143 "the offset runs the solid's faces through each other and \
144 collapses it"
145 );
146 }
147 Ok(built)
148}
149
150pub fn offset_faces(
165 model: &mut Model,
166 solid: &Shape,
167 faces: &[Shape],
168 distance: f64,
169 tol: Tolerances,
170) -> OgeomResult<Built> {
171 if !distance.is_finite() || distance.abs() <= tol.confusion() {
172 ogeom_bail!(Construction, "an offset of {distance} moves nothing");
173 }
174 let (canonical, mapped, prefix) = canonical_input(model, solid, faces, tol)?;
175 if let Some(prefix) = prefix {
176 let mut out = offset_faces(model, &canonical, &mapped, distance, tol)?;
177 out.history = prefix.then(&out.history);
178 return Ok(out);
179 }
180 let chosen = chosen_faces(model, solid, faces)?;
181 let built = rebuilt(
182 model,
183 solid,
184 &|face| {
185 if chosen.contains(&face.node()) {
186 distance
187 } else {
188 0.0
189 }
190 },
191 &|_| None,
192 tol,
193 )?;
194 still_sound(model, solid, &chosen, built, tol)
195}
196
197pub fn move_faces(
207 model: &mut Model,
208 solid: &Shape,
209 faces: &[Shape],
210 transform: &ogeom_math::Transform,
211 tol: Tolerances,
212) -> OgeomResult<Built> {
213 use ogeom_geom::Transformable as _;
214 use ogeom_math::TransformKind;
215 if !matches!(
216 transform.kind(),
217 TransformKind::Identity | TransformKind::Translation | TransformKind::Rotation
218 ) {
219 ogeom_bail!(
220 Construction,
221 "moving faces takes a translation or a rotation; a scale or a \
222 reflection reshapes them"
223 );
224 }
225 if transform.kind() == TransformKind::Identity {
226 ogeom_bail!(Construction, "the identity moves nothing");
227 }
228 let (canonical, mapped, prefix) = canonical_input(model, solid, faces, tol)?;
229 if let Some(prefix) = prefix {
230 let mut out = move_faces(model, &canonical, &mapped, transform, tol)?;
231 out.history = prefix.then(&out.history);
232 return Ok(out);
233 }
234 let chosen = chosen_faces(model, solid, faces)?;
235 let mut moved: HashMap<TShapeId, SurfaceGeometry> = HashMap::new();
238 for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
239 if !chosen.contains(&face.node()) || moved.contains_key(&face.node()) {
240 continue;
241 }
242 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
243 ogeom_bail!(Construction, "face node holds no face data");
244 };
245 let Some(surface) = model.geometry().surface(data.surface) else {
246 ogeom_bail!(Dangling, "face refers to a surface not in this model");
247 };
248 let placement = face.transform(model.datums())?;
249 let local = placement.inverse()? * *transform * placement;
250 moved.insert(face.node(), surface.transformed(&local, tol)?);
251 }
252 let built = rebuilt(
253 model,
254 solid,
255 &|_| 0.0,
256 &|face| moved.get(&face.node()).cloned(),
257 tol,
258 )?;
259 still_sound(model, solid, &chosen, built, tol)
260}
261
262fn still_sound(
269 model: &Model,
270 solid: &Shape,
271 chosen: &std::collections::HashSet<TShapeId>,
272 built: Built,
273 tol: Tolerances,
274) -> OgeomResult<Built> {
275 let faces = explore_unique(model, solid, ShapeType::Face)?;
276 let mut corners: std::collections::HashSet<TShapeId> = std::collections::HashSet::new();
277 for face in faces.iter().filter(|f| chosen.contains(&f.node())) {
278 for v in explore_unique(model, face, ShapeType::Vertex)? {
279 corners.insert(v.node());
280 }
281 }
282 let mut touched: Vec<Shape> = Vec::new();
283 for face in &faces {
284 let near = chosen.contains(&face.node())
285 || explore_unique(model, face, ShapeType::Vertex)?
286 .iter()
287 .any(|v| corners.contains(&v.node()));
288 if near {
289 touched.extend(built.history.trace(face).iter().cloned());
290 }
291 }
292 if !ogeom_algo::inside_out_faces(model, &built.shape, tol)?.is_empty() {
293 ogeom_bail!(
294 Construction,
295 "the moved faces run past the faces across from them and turn \
296 the solid inside out"
297 );
298 }
299 if !ogeom_algo::check_self_intersection_near(model, &built.shape, &touched, tol)?.is_empty() {
300 ogeom_bail!(
301 Construction,
302 "the moved faces run into the rest of the solid; the edit would \
303 make it cross itself"
304 );
305 }
306 Ok(built)
307}
308
309fn chosen_faces(
311 model: &Model,
312 solid: &Shape,
313 faces: &[Shape],
314) -> OgeomResult<std::collections::HashSet<TShapeId>> {
315 if faces.is_empty() {
316 ogeom_bail!(Construction, "no face was named to move");
317 }
318 let own: std::collections::HashSet<TShapeId> =
319 explore(model, solid, Filter::OfType(ShapeType::Face))?
320 .iter()
321 .map(Shape::node)
322 .collect();
323 for face in faces {
324 if !own.contains(&face.node()) {
325 ogeom_bail!(Construction, "a named face is not a face of the solid");
326 }
327 }
328 Ok(faces.iter().map(Shape::node).collect())
329}
330
331pub fn make_thick_solid(
352 model: &mut Model,
353 solid: &Shape,
354 removed: &[Shape],
355 thickness: f64,
356 tol: Tolerances,
357) -> OgeomResult<Built> {
358 make_thick_solid_with(model, solid, removed, thickness, Join::Intersection, tol)
359}
360
361pub fn make_thick_solid_with(
376 model: &mut Model,
377 solid: &Shape,
378 removed: &[Shape],
379 thickness: f64,
380 join: Join,
381 tol: Tolerances,
382) -> OgeomResult<Built> {
383 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
384 ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
385 }
386 let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
387 if let Some(prefix) = prefix {
388 let mut out = make_thick_solid_with(model, &canonical, &mapped, thickness, join, tol)?;
389 out.history = prefix.then(&out.history);
390 return Ok(out);
391 }
392 let outward_walls = thickness < 0.0;
395 let reach = thickness.abs();
396 let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
397 .iter()
398 .map(Shape::node)
399 .collect();
400 for face in removed {
401 if !own.contains(&face.node()) {
402 ogeom_bail!(Construction, "a removed face is not a face of the solid");
403 }
404 }
405
406 let mut tangent_opening = false;
407 for face in removed {
408 if has_tangent_neighbour(model, solid, face, tol)? {
409 tangent_opening = true;
410 break;
411 }
412 }
413 if !tangent_opening {
414 let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
415 let moved = rebuilt(
416 model,
417 solid,
418 &|face| {
419 if skip.contains(&face.node()) {
420 0.0
421 } else if outward_walls {
422 reach
423 } else {
424 -reach
425 }
426 },
427 &|_| None,
428 tol,
429 )?;
430 let moved = right_side_out(model, moved, tol)?;
431 let moved = if join == Join::Arc {
435 let held: Vec<TShapeId> = removed
436 .iter()
437 .flat_map(|f| {
438 moved
439 .history
440 .modified(f)
441 .iter()
442 .map(Shape::node)
443 .collect::<Vec<_>>()
444 })
445 .collect();
446 let edges = growing_edges(model, &moved.shape, &held, outward_walls, tol)?;
447 if edges.is_empty() {
448 moved
449 } else {
450 ogeom_fillet::fillet_edges(model, &moved.shape, &edges, reach, tol)?
451 }
452 } else {
453 moved
454 };
455 let mut result = if outward_walls {
460 ogeom_bool::cut(model, &moved.shape, solid, tol)?
461 } else {
462 ogeom_bool::cut(model, solid, &moved.shape, tol)?
463 };
464 for face in removed {
465 result.history.delete(face);
466 }
467 return Ok(result);
468 }
469
470 if join == Join::Arc {
471 ogeom_bail!(
472 Construction,
473 "the arc join is built where every opening meets its neighbours \
474 across a corner; an opening with a tangent neighbour is not yet"
475 );
476 }
477 let displaced = if outward_walls { reach } else { -reach };
481 let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
482 let moved = right_side_out(model, moved, tol)?;
483 let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
484 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
485 ogeom_bail!(Construction, "face node holds no face data");
486 };
487 let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
488 ogeom_bail!(
489 Construction,
490 "a tangent opening must be planar; a curved opening needs \
491 the general rebuild; see docs/PARITY.md, offset.shell-thicken"
492 );
493 };
494 let mut normal = p.plane().normal().vector();
495 if face.orientation() == Orientation::Reversed {
496 normal = -normal;
497 }
498 Ok(normal)
499 };
500 let mut result = if outward_walls {
501 let mut tool = solid.clone();
504 for face in removed {
505 let outward = opening_normal(model, face)?;
506 let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
507 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
508 }
509 ogeom_bool::cut(model, &moved.shape, &tool, tol)?
510 } else {
511 let mut tool = moved.shape.clone();
512 for face in removed {
513 let outward = opening_normal(model, face)?;
514 let [image] = moved.history.modified(face) else {
515 ogeom_bail!(Construction, "a removed face has no single cavity image");
516 };
517 let punch =
518 ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
519 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
520 }
521 ogeom_bool::cut(model, solid, &tool, tol)?
522 };
523 for face in removed {
524 result.history.delete(face);
525 }
526 Ok(result)
527}
528
529fn has_tangent_neighbour(
531 model: &Model,
532 solid: &Shape,
533 face: &Shape,
534 tol: Tolerances,
535) -> OgeomResult<bool> {
536 use ogeom_geom::Surface as _;
537
538 let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
539 .iter()
540 .map(Shape::node)
541 .collect();
542 let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
543 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
544 ogeom_bail!(Construction, "face node holds no face data");
545 };
546 let Some(surface) = model.geometry().surface(data.surface) else {
547 ogeom_bail!(Dangling, "face refers to a surface not in this model");
548 };
549 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
550 if projection.distance > tol.confusion() * 100.0 {
551 return Ok(None);
552 }
553 let (u, v) = projection.parameters;
554 let (du, dv) = surface.d1_at(u, v, tol)?;
555 let n = du.cross(dv);
556 let m = n.magnitude();
557 if m <= tol.confusion() {
558 return Ok(None);
559 }
560 Ok(Some(n / m))
561 };
562 for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
563 if other.node() == face.node() {
564 continue;
565 }
566 for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
567 if !own_edges.contains(&edge.node()) {
568 continue;
569 }
570 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
571 continue;
572 };
573 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
574 continue;
575 };
576 let Some(geometry) = model.geometry().curve(*curve) else {
577 continue;
578 };
579 let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
580 let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
581 else {
582 continue;
583 };
584 if a.cross(b).magnitude() <= 1e-6 {
585 return Ok(true);
586 }
587 }
588 }
589 Ok(false)
590}
591
592struct Prepared {
594 shape: Shape,
595 surface: SurfaceGeometry,
597 amount: f64,
599 sign: f64,
602 rings: Option<[Shape; 2]>,
604}
605
606pub(crate) fn rebuilt(
627 model: &mut Model,
628 solid: &Shape,
629 amount_of: &dyn Fn(&Shape) -> f64,
630 instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
631 tol: Tolerances,
632) -> OgeomResult<Built> {
633 use ogeom_geom::Surface as _;
634 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
635
636 let span = ogeom_algo::shape_bounds(model, solid, tol)?.diagonal();
638 let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
639 for face in &faces {
640 let amount = amount_of(face);
641 let Some(node) = model.node(face) else {
642 ogeom_bail!(Dangling, "face is not in this model");
643 };
644 let NodeData::Face(data) = node.data() else {
645 ogeom_bail!(Construction, "face node holds no face data");
646 };
647 let Some(surface) = model.geometry().surface(data.surface) else {
648 ogeom_bail!(Dangling, "face refers to a surface not in this model");
649 };
650 let sign = if face.orientation() == Orientation::Reversed {
651 -1.0
652 } else {
653 1.0
654 };
655 let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
656 let mut counts: HashMap<TShapeId, usize> = HashMap::new();
657 for e in &edges {
658 *counts.entry(e.node()).or_insert(0) += 1;
659 }
660 let has_seam = counts.values().any(|c| *c >= 2);
661 let closed_rings: Vec<Shape> = edges
662 .iter()
663 .filter(|e| {
664 edge_vertices(model, e)
665 .ok()
666 .flatten()
667 .is_some_and(|(a, b)| a.node() == b.node())
668 })
669 .cloned()
670 .collect();
671
672 let both_poles = closed_rings.len() == 2
673 && closed_rings.iter().all(|ring| {
674 model
675 .node(ring)
676 .and_then(|n| n.data().as_edge())
677 .is_some_and(|d| d.degenerate)
678 });
679 let grow = amount.abs() * 4.0 + 1.0;
680 let replacement = instead_of(face);
681 let moved: SurfaceGeometry = if let Some(given) = replacement {
682 given
683 } else if amount == 0.0 {
684 match surface {
691 SurfaceGeometry::Plane(p) => {
692 let ((u0, u1), (v0, v1)) = surface.domain();
693 PlaneSurface::over(p.plane(), (u0 - span, u1 + span), (v0 - span, v1 + span))?
694 .into()
695 }
696 SurfaceGeometry::Cylinder(c) => {
697 let (_, (v0, v1)) = surface.domain();
698 CylinderSurface::new(c.cylinder(), (v0 - span, v1 + span))?.into()
699 }
700 SurfaceGeometry::Cone(co) => {
701 let (_, (v0, v1)) = surface.domain();
704 let apex = co.apex_height();
705 let (lo, hi) = if apex <= v0 {
706 ((v0 - span).max(apex), v1 + span)
707 } else {
708 (v0 - span, (v1 + span).min(apex))
709 };
710 ogeom_geom::ConeSurface::new(co.cone(), (lo, hi))?.into()
711 }
712 _ => surface.clone(),
713 }
714 } else {
715 match surface {
716 SurfaceGeometry::Plane(p) => {
717 let plane = p.plane();
718 let ((u0, u1), (v0, v1)) = surface.domain();
719 let shifted = Plane::new(Frame::new(
720 plane.origin() + plane.normal().vector() * (sign * amount),
721 plane.normal(),
722 plane.frame().x(),
723 tol,
724 )?);
725 PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
726 .into()
727 }
728 SurfaceGeometry::Cylinder(c) => {
729 let cylinder = c.cylinder();
730 let grown = sign.mul_add(amount, cylinder.radius());
731 if grown <= tol.confusion() {
732 ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
733 }
734 let (_, (v0, v1)) = surface.domain();
735 CylinderSurface::new(
736 Cylinder::new(cylinder.frame(), grown, tol)?,
737 (v0 - grow, v1 + grow),
738 )?
739 .into()
740 }
741 SurfaceGeometry::Sphere(sp) => {
742 let sphere = sp.sphere();
743 let grown = sign.mul_add(amount, sphere.radius());
744 if grown <= tol.confusion() {
745 ogeom_bail!(Construction, "the offset consumes the sphere's radius");
746 }
747 ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
748 sphere.centre(),
749 grown,
750 tol,
751 )?)
752 .into()
753 }
754 SurfaceGeometry::Torus(t) => {
755 let torus = t.torus();
756 let grown = sign.mul_add(amount, torus.minor_radius());
757 if grown <= tol.confusion() {
758 ogeom_bail!(Construction, "the offset consumes the torus's tube");
759 }
760 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
761 torus.frame(),
762 torus.major_radius(),
763 grown,
764 tol,
765 )?)
766 .into()
767 }
768 SurfaceGeometry::Cone(co) => {
769 let cone = co.cone();
770 let grown = (sign * amount / cone.half_angle().cos())
773 .mul_add(1.0, cone.reference_radius());
774 if grown <= tol.confusion() {
775 ogeom_bail!(Construction, "the offset consumes the cone's throat");
776 }
777 let (_, (v0, v1)) = surface.domain();
778 ogeom_geom::ConeSurface::new(
779 ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
780 (v0 - grow, v1 + grow),
781 )?
782 .into()
783 }
784 _ => ogeom_bail!(
785 Construction,
786 "offsetting a face on this surface needs a construction \
787 the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
788 ),
789 }
790 };
791 let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
795 prepared.push(Prepared {
796 shape: face.clone(),
797 surface: moved,
798 amount,
799 sign,
800 rings: if has_seam && closed_rings.len() == 2 && !fitted_support && !both_poles {
804 Some([closed_rings[0].clone(), closed_rings[1].clone()])
805 } else {
806 None
807 },
808 });
809 }
810
811 let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
813 for (fi, face) in faces.iter().enumerate() {
814 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
815 let entry = edge_faces.entry(e.node()).or_default();
816 if !entry.contains(&fi) {
817 entry.push(fi);
818 }
819 }
820 }
821
822 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
826 let face = &faces[fi];
827 let Some(node) = model.node(face) else {
828 ogeom_bail!(Dangling, "face is not in this model");
829 };
830 let NodeData::Face(data) = node.data() else {
831 ogeom_bail!(Construction, "face node holds no face data");
832 };
833 let Some(surface) = model.geometry().surface(data.surface) else {
834 ogeom_bail!(Dangling, "face refers to a surface not in this model");
835 };
836 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
837 if projection.distance > tol.confusion() * 100.0 {
838 return Ok(None);
839 }
840 let (u, v) = projection.parameters;
841 let (du, dv) = surface.d1_at(u, v, tol)?;
842 let n = du.cross(dv);
843 let m = n.magnitude();
844 if m <= tol.confusion() {
845 return Ok(None);
846 }
847 let outward = n / m * prepared[fi].sign;
848 Ok(Some((outward, prepared[fi].amount)))
849 };
850
851 let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
855 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
856 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
857 continue;
858 };
859 let at = vertex.transform(model.datums())?.apply(data.point);
860 let mut seats: Vec<usize> = Vec::new();
861 for (fi, face) in faces.iter().enumerate() {
862 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
863 if v.node() == vertex.node() && !seats.contains(&fi) {
864 seats.push(fi);
865 }
866 }
867 }
868 if seats.is_empty() {
869 continue;
870 }
871 let mut normals: Vec<Vector> = Vec::new();
874 let mut amounts: Vec<f64> = Vec::new();
875 let mut kept: Vec<usize> = Vec::new();
876 for fi in &seats {
877 let Some((n, w)) = constraint(model, *fi, at)? else {
878 continue;
879 };
880 if let Some(k) = normals
881 .iter()
882 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
883 {
884 if (amounts[k] - w).abs() > tol.confusion() {
885 ogeom_bail!(
886 Construction,
887 "two tangent faces move a shared vertex by different \
888 amounts; the offset tears it"
889 );
890 }
891 continue;
892 }
893 normals.push(n);
894 amounts.push(w);
895 kept.push(*fi);
896 }
897 if normals.is_empty() {
898 let mut apex: Option<Point> = None;
902 for fi in &seats {
903 let Some(node) = model.node(&faces[*fi]) else {
904 continue;
905 };
906 let NodeData::Face(data) = node.data() else {
907 continue;
908 };
909 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
910 else {
911 continue;
912 };
913 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
914 continue;
915 }
916 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
917 apex = Some(moved_cone.cone().apex());
918 break;
919 }
920 }
921 if apex.is_none() {
926 for fi in &seats {
927 let Some(NodeData::Face(data)) = model.node(&faces[*fi]).map(|n| n.data())
928 else {
929 continue;
930 };
931 let Some(old @ SurfaceGeometry::Sphere(_)) =
932 model.geometry().surface(data.surface)
933 else {
934 continue;
935 };
936 if !matches!(prepared[*fi].surface, SurfaceGeometry::Sphere(_)) {
937 continue;
938 }
939 let local = faces[*fi].transform(model.datums())?;
940 let found =
941 ogeom_algo::project_on_surface(old, local.inverse()?.apply(at), 32, tol)?;
942 if found.distance > tol.confusion() * 100.0 {
943 continue;
944 }
945 let (u, v) = found.parameters;
946 apex = Some(local.apply(prepared[*fi].surface.point_at(u, v, tol)?));
947 break;
948 }
949 }
950 let Some(moved) = apex else {
951 ogeom_bail!(
952 Construction,
953 "a vertex with no seat the rebuild can read cannot be \
954 re-solved"
955 );
956 };
957 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
958 continue;
959 }
960 if normals.len() == 1 {
961 let moved = at + normals[0] * amounts[0];
966 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
967 continue;
968 }
969 if kept.len() == 2
975 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
976 {
977 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
978 continue;
979 }
980 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
981 for _ in 0..8 {
985 let mut ns: Vec<Vector> = Vec::new();
986 let mut rs: Vec<f64> = Vec::new();
987 for fi in &kept {
988 let projection =
989 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
990 let (u, v) = projection.parameters;
991 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
992 let n = du.cross(dv);
993 let m = n.magnitude();
994 if m <= tol.confusion() {
995 continue;
996 }
997 let n = n / m;
998 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
999 ns.push(n);
1000 rs.push((moved - foot).dot(n));
1001 }
1002 if ns.len() < 2 {
1003 break;
1004 }
1005 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
1006 if worst <= tol.confusion() * 0.1 {
1007 break;
1008 }
1009 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
1010 moved += solve_corner(&ns, &step, tol)?;
1011 }
1012 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1013 }
1014
1015 let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
1018 for face in &faces {
1019 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1020 *edge_uses.entry(e.node()).or_insert(0) += 1;
1021 }
1022 }
1023
1024 let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
1026 let mut history = History::new();
1027 for edge in explore_unique(model, solid, ShapeType::Edge)? {
1028 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
1029 if sides.len() != 2 {
1030 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
1031 if let [fi] = sides.as_slice()
1036 && let Some(built) =
1037 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
1038 {
1039 history.modify(&edge, built.clone());
1040 new_edges.insert(edge.node(), built);
1041 }
1042 continue;
1043 }
1044 let Some(built) =
1047 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
1048 else {
1049 ogeom_bail!(
1050 Construction,
1051 "an edge with one face is neither a ring nor an apex; \
1052 the offset cannot re-derive it"
1053 );
1054 };
1055 history.modify(&edge, built.clone());
1056 new_edges.insert(edge.node(), built);
1057 continue;
1058 }
1059 let (curve, range) = {
1060 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1061 ogeom_bail!(Construction, "edge node holds no edge data");
1062 };
1063 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1064 ogeom_bail!(Construction, "an edge has no curve to offset");
1065 };
1066 let Some(geometry) = model.geometry().curve(*curve) else {
1067 ogeom_bail!(Dangling, "curve is not in this model");
1068 };
1069 (geometry.clone(), *range)
1070 };
1071 let forward = if edge.orientation() == Orientation::Reversed {
1072 edge.reversed()
1073 } else {
1074 edge.clone()
1075 };
1076 let built = match &curve {
1077 Curve::Line(_) => {
1078 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1085 ogeom_bail!(Construction, "a straight edge has no vertices");
1086 };
1087 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1088 new_vertices.get(&sv.node()).cloned(),
1089 new_vertices.get(&ev.node()).cloned(),
1090 ) else {
1091 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1092 };
1093 if p_to.distance(p_from) <= tol.parametric() {
1094 ogeom_bail!(Construction, "the offset collapses an edge");
1095 }
1096 let segment = LineCurve::segment(p_from, p_to, tol)?;
1097 let (t0, t1) = segment.domain();
1098 let moved: Curve = segment.into();
1099 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1100 }
1101 Curve::Circle(c)
1102 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
1103 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
1104 {
1105 let circle = c.circle();
1110 let found = ogeom_intersect::intersect_surfaces(
1111 &prepared[sides[0]].surface,
1112 &prepared[sides[1]].surface,
1113 ogeom_intersect::IntersectOptions::default(),
1114 tol,
1115 )?;
1116 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1117 ogeom_bail!(
1118 Construction,
1119 "the moved faces no longer meet along the edge they \
1120 shared; the offset collapses it"
1121 );
1122 };
1123 let mut best: Option<(ogeom_math::Circle, f64)> = None;
1124 for section in &candidates {
1125 let Curve::Circle(cc) = §ion.curve else {
1126 continue;
1127 };
1128 let candidate = cc.circle();
1129 let score = candidate.centre().distance(circle.centre())
1130 + (candidate.radius() - circle.radius()).abs();
1131 if best.as_ref().is_none_or(|(_, held)| score < *held) {
1132 best = Some((candidate, score));
1133 }
1134 }
1135 let Some((candidate, _)) = best else {
1136 ogeom_bail!(
1137 Construction,
1138 "the moved faces meet along nothing circular where a \
1139 circle was; the offset needs the general rebuild"
1140 );
1141 };
1142 let reframed = ogeom_math::Circle::new(
1143 Frame::new(
1144 candidate.centre(),
1145 circle.frame().z(),
1146 circle.frame().x(),
1147 tol,
1148 )?,
1149 candidate.radius(),
1150 tol,
1151 )?;
1152 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1153 let closed = {
1154 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1155 ogeom_bail!(Construction, "a ring has no vertex");
1156 };
1157 sv.node() == ev.node()
1158 };
1159 if closed {
1160 make_edge(model, moved, range, tol)?.shape
1161 } else {
1162 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1163 ogeom_bail!(Construction, "an arc has no vertices");
1164 };
1165 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1166 new_vertices.get(&sv.node()).cloned(),
1167 new_vertices.get(&ev.node()).cloned(),
1168 ) else {
1169 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
1170 };
1171 let angle_of = |p: Point| {
1172 let l = reframed.frame().to_local(p);
1173 l.y.atan2(l.x)
1174 };
1175 let tau = core::f64::consts::TAU;
1176 let mut t0 = angle_of(p_from);
1177 let mut t1 = angle_of(p_to);
1178 while t0 < range.0 - core::f64::consts::PI {
1180 t0 += tau;
1181 }
1182 while t0 > range.0 + core::f64::consts::PI {
1183 t0 -= tau;
1184 }
1185 while t1 <= t0 + tol.parametric() {
1186 t1 += tau;
1187 }
1188 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
1189 t1 -= tau;
1190 }
1191 if t1 <= t0 + tol.parametric() {
1192 ogeom_bail!(Construction, "the offset collapses an arc");
1193 }
1194 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1195 }
1196 }
1197 _ => {
1198 let unmoved = {
1203 let mut worst = 0.0_f64;
1204 'probe: for i in 0..9 {
1205 #[allow(clippy::cast_precision_loss)]
1206 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
1207 let p = curve.point_at(t, tol)?;
1208 for side in [sides[0], sides[1]] {
1209 let Ok(near) =
1210 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
1211 else {
1212 worst = f64::INFINITY;
1213 break 'probe;
1214 };
1215 worst = worst.max(near.distance);
1216 }
1217 }
1218 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
1222 };
1223 if let Some(worst) = unmoved {
1224 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1225 ogeom_bail!(Construction, "an edge has no vertices");
1226 };
1227 let closed = sv.node() == ev.node();
1228 let built = if closed {
1229 match new_vertices.get(&sv.node()).cloned() {
1232 Some((v_at, p_at)) => {
1233 let gap = curve.point_at(range.0, tol)?.distance(p_at);
1234 if gap > tol.confusion() {
1235 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1236 }
1237 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
1238 .shape
1239 }
1240 None => make_edge(model, curve.clone(), range, tol)?.shape,
1241 }
1242 } else {
1243 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1244 new_vertices.get(&sv.node()).cloned(),
1245 new_vertices.get(&ev.node()).cloned(),
1246 ) else {
1247 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1248 };
1249 let gap = curve
1253 .point_at(range.0, tol)?
1254 .distance(p_from)
1255 .min(curve.point_at(range.0, tol)?.distance(p_to))
1256 .max(
1257 curve
1258 .point_at(range.1, tol)?
1259 .distance(p_to)
1260 .min(curve.point_at(range.1, tol)?.distance(p_from)),
1261 );
1262 if gap > tol.confusion() {
1263 for v in [&v_from, &v_to] {
1264 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1265 }
1266 }
1267 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
1268 };
1269 if worst > tol.confusion()
1270 && let Some(node) = model.node_mut(&built)
1271 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1272 {
1273 data.tolerance = data.tolerance.widen_to(worst);
1274 }
1275 history.modify(&edge, built.clone());
1276 new_edges.insert(edge.node(), built);
1277 continue;
1278 }
1279 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1286 let found = ogeom_intersect::intersect_surfaces(
1287 &prepared[sides[0]].surface,
1288 &prepared[sides[1]].surface,
1289 ogeom_intersect::IntersectOptions::default(),
1290 tol,
1291 )?;
1292 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1293 ogeom_bail!(
1294 Construction,
1295 "the moved faces no longer meet along the edge they \
1296 shared; the offset collapses it"
1297 );
1298 };
1299 let mut best: Option<(Curve, f64, f64)> = None;
1300 for section in candidates {
1301 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
1302 else {
1303 continue;
1304 };
1305 if best
1306 .as_ref()
1307 .is_none_or(|(_, _, held)| projected.distance < *held)
1308 {
1309 best = Some((section.curve, section.tolerance, projected.distance));
1310 }
1311 }
1312 let Some((moved, slop, _)) = best else {
1313 ogeom_bail!(
1314 Construction,
1315 "the moved faces meet along nothing where the edge \
1316 was; the offset collapses it"
1317 );
1318 };
1319 let closed = {
1320 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1321 ogeom_bail!(Construction, "an edge has no vertices");
1322 };
1323 sv.node() == ev.node()
1324 };
1325 let built = if closed {
1326 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1332 ogeom_bail!(Construction, "a ring has no vertex");
1333 };
1334 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1335 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1336 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1337 let (lo, hi) = moved.domain();
1338 let seamed: Curve = if t.parameter > lo + tol.parametric()
1339 && t.parameter < hi - tol.parametric()
1340 {
1341 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1342 } else {
1343 moved.clone()
1344 };
1345 let seamed = {
1349 use ogeom_geom::Reversible as _;
1350 let (a, _) = seamed.domain();
1351 let old = curve.d1_at(range.0, tol)?;
1352 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1353 seamed.reversed()
1354 } else {
1355 seamed
1356 }
1357 };
1358 let miss = t.distance.max(slop);
1359 if miss > tol.confusion() {
1360 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1361 }
1362 let window = seamed.domain();
1363 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1364 }
1365 _ => {
1366 let window = moved.domain();
1367 make_edge(model, moved, window, tol)?.shape
1368 }
1369 }
1370 } else {
1371 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1372 ogeom_bail!(Construction, "an edge has no vertices");
1373 };
1374 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1375 new_vertices.get(&sv.node()).cloned(),
1376 new_vertices.get(&ev.node()).cloned(),
1377 ) else {
1378 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1379 };
1380 if slop > tol.confusion() {
1383 for v in [&v_from, &v_to] {
1384 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1385 }
1386 }
1387 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1388 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1389 if (tb - ta).abs() <= tol.parametric() {
1390 ogeom_bail!(Construction, "the offset collapses an edge");
1391 }
1392 let (moved, ta, tb) = if ta <= tb {
1396 (moved, ta, tb)
1397 } else {
1398 use ogeom_geom::Reversible as _;
1399 let (lo, hi) = moved.domain();
1400 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1401 };
1402 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1403 };
1404 if slop > tol.confusion()
1405 && let Some(node) = model.node_mut(&built)
1406 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1407 {
1408 data.tolerance = data.tolerance.widen_to(slop);
1409 }
1410 built
1411 }
1412 };
1413 history.modify(&edge, built.clone());
1414 new_edges.insert(edge.node(), built);
1415 }
1416
1417 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1420 for prep in &prepared {
1421 let built = if let Some(rings) = &prep.rings {
1422 let (Some(lo), Some(hi)) = (
1423 new_edges.get(&rings[0].node()),
1424 new_edges.get(&rings[1].node()),
1425 ) else {
1426 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1427 };
1428 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1429 if prep.shape.orientation() == Orientation::Reversed {
1430 band.reversed()
1431 } else {
1432 band
1433 }
1434 } else {
1435 let mut wires: Vec<Vec<Shape>> = Vec::new();
1436 let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1437 for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1438 let mut edges: Vec<Shape> = Vec::new();
1439 for used in model.ordered_children_of(&wire)? {
1442 *face_uses.entry(used.node()).or_insert(0) += 1;
1443 let Some(fresh) = new_edges.get(&used.node()) else {
1444 ogeom_bail!(Construction, "a face edge was not rebuilt");
1445 };
1446 edges.push(if used.orientation() == Orientation::Reversed {
1447 fresh.reversed()
1448 } else {
1449 fresh.clone()
1450 });
1451 }
1452 wires.push(edges);
1453 }
1454 let face = if face_uses.values().any(|c| *c >= 2) {
1455 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1460 } else {
1461 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1462 };
1463 if prep.shape.orientation() == Orientation::Reversed {
1464 face.reversed()
1465 } else {
1466 face
1467 }
1468 };
1469 history.modify(&prep.shape, built.clone());
1470 rebuilt_faces.push(built);
1471 }
1472
1473 let sewn = sew(model, &rebuilt_faces, tol)?;
1474 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1475 ogeom_bail!(Construction, "the offset solid did not close");
1476 }
1477 let outer = {
1481 let old_reversed = model
1482 .children_of(solid)?
1483 .first()
1484 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1485 if old_reversed {
1486 sewn.shells[0].reversed()
1487 } else {
1488 sewn.shells[0].clone()
1489 }
1490 };
1491 let built = make_solid(model, std::slice::from_ref(&outer))?;
1492
1493 let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1504 let fine = (size * 1e-5).clamp(
1505 tol.confusion() * 1e2,
1506 ogeom_mesh::Deflection::default().chord,
1507 );
1508 let mut mass = None;
1509 let mut first_error = None;
1510 for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1511 let deflection = ogeom_mesh::Deflection {
1512 chord,
1513 ..ogeom_mesh::Deflection::default()
1514 };
1515 match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1516 Ok(props) => {
1517 mass = Some(props.mass);
1518 break;
1519 }
1520 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1521 return Err(e);
1522 }
1523 Err(e) => {
1524 first_error.get_or_insert(e);
1525 }
1526 }
1527 }
1528 let Some(mass) = mass else {
1529 ogeom_bail!(
1530 Construction,
1531 "the offset solid's mesh does not close at any tried resolution{}",
1532 first_error.map_or_else(String::new, |e| format!(": {e}"))
1533 );
1534 };
1535 if !mass.is_finite() || mass <= tol.confusion() {
1536 ogeom_bail!(Construction, "the offset collapses the solid");
1537 }
1538
1539 history.modify(solid, built.shape.clone());
1540 Ok(Built::new(built.shape, history))
1541}
1542
1543fn rebuilt_seam_edge(
1552 model: &mut Model,
1553 edge: &Shape,
1554 prep: &Prepared,
1555 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1556 tol: Tolerances,
1557) -> OgeomResult<Option<Shape>> {
1558 use ogeom_geom::Curve2d as _;
1559
1560 let old_surface = {
1561 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1562 else {
1563 ogeom_bail!(Construction, "face node holds no face data");
1564 };
1565 data.surface
1566 };
1567 let found = {
1568 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1569 ogeom_bail!(Construction, "edge node holds no edge data");
1570 };
1571 let mut found = None;
1572 for repr in &data.representations {
1573 if let EdgeRepr::Seam {
1574 forward,
1575 surface,
1576 range,
1577 ..
1578 } = repr
1579 && *surface == old_surface
1580 {
1581 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1582 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1583 };
1584 found = Some(pcurve.point_at(range.0, tol)?.x);
1588 break;
1589 }
1590 }
1591 found
1592 };
1593 let Some(column) = found else {
1594 return Ok(None);
1595 };
1596 let forward = if edge.orientation() == Orientation::Reversed {
1600 edge.reversed()
1601 } else {
1602 edge.clone()
1603 };
1604 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1605 ogeom_bail!(Construction, "a seam has no vertices");
1606 };
1607 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1608 new_vertices.get(&sv.node()).cloned(),
1609 new_vertices.get(&ev.node()).cloned(),
1610 ) else {
1611 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1612 };
1613 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1614 ogeom_bail!(
1615 Construction,
1616 "the moved surface's iso-curve has no closed form; no seam can \
1617 be rebuilt"
1618 );
1619 };
1620 let along = |p: Point| -> OgeomResult<f64> {
1624 match &curve {
1625 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1626 Curve::Circle(c) => {
1627 let local = c.circle().frame().to_local(p);
1628 let mut angle = local.y.atan2(local.x);
1629 if angle < 0.0 {
1630 angle += core::f64::consts::TAU;
1631 }
1632 Ok(angle)
1633 }
1634 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1638 }
1639 };
1640 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1641 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1651 (tol.confusion() * 1e3).max(1e-4) * 4.0
1652 } else {
1653 tol.confusion() * 100.0
1654 };
1655 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1656 let off = curve.point_at(t, tol)?.distance(p);
1657 if off > slack {
1658 return Ok(None);
1659 }
1660 if off > tol.confusion() {
1663 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1664 }
1665 }
1666 Ok(Some(if t_start <= t_end {
1667 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1668 } else {
1669 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1673 .shape
1674 .reversed()
1675 }))
1676}
1677
1678fn assembled_with_seam(
1686 model: &mut Model,
1687 prep: &Prepared,
1688 wires: &[Vec<Shape>],
1689 new_edges: &HashMap<TShapeId, Shape>,
1690 tol: Tolerances,
1691) -> OgeomResult<Shape> {
1692 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1693 for edges in wires {
1694 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1695 }
1696 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1697 let new_surface = {
1698 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1699 ogeom_bail!(Construction, "the face just built holds no face data");
1700 };
1701 data.surface
1702 };
1703 let old_surface = {
1704 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1705 else {
1706 ogeom_bail!(Construction, "face node holds no face data");
1707 };
1708 data.surface
1709 };
1710
1711 let mut done: Vec<TShapeId> = Vec::new();
1712 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1713 if done.contains(&used.node()) {
1714 continue;
1715 }
1716 done.push(used.node());
1717 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1718 ogeom_bail!(Construction, "a face edge was not rebuilt");
1719 };
1720 if model
1723 .node(&fresh)
1724 .and_then(|n| n.data().as_edge())
1725 .is_some_and(|d| d.degenerate)
1726 {
1727 let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1728 .node(&used)
1729 .and_then(|n| n.data().as_edge())
1730 .and_then(|d| d.pcurve_for(old_surface, used.location()))
1731 .cloned()
1732 else {
1733 ogeom_bail!(Construction, "a pole has no row on its face");
1734 };
1735 let Some(row) = model.geometry().pcurve(curve).cloned() else {
1736 ogeom_bail!(Dangling, "a pole's row is not in this model");
1737 };
1738 ogeom_algo::attach_pcurve(
1739 model,
1740 &fresh,
1741 row,
1742 new_surface,
1743 ogeom_topo::Location::identity(),
1744 range,
1745 )?;
1746 continue;
1747 }
1748 let (fresh_curve, fresh_range) = {
1749 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1750 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1751 };
1752 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1753 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1754 };
1755 let Some(geometry) = model.geometry().curve(*curve) else {
1756 ogeom_bail!(Dangling, "curve is not in this model");
1757 };
1758 (geometry.clone(), *range)
1759 };
1760 let columns = {
1761 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1762 ogeom_bail!(Construction, "edge node holds no edge data");
1763 };
1764 let mut columns = None;
1765 for repr in &data.representations {
1766 if let EdgeRepr::Seam {
1767 forward,
1768 reversed,
1769 surface,
1770 range,
1771 ..
1772 } = repr
1773 && *surface == old_surface
1774 {
1775 use ogeom_geom::Curve2d as _;
1776 let (Some(f), Some(r)) = (
1777 model.geometry().pcurve(*forward),
1778 model.geometry().pcurve(*reversed),
1779 ) else {
1780 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1781 };
1782 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1783 break;
1784 }
1785 }
1786 columns
1787 };
1788 if let Some((forward_col, reversed_col)) = columns {
1789 let rows = match &prep.surface {
1792 SurfaceGeometry::Cone(c) => {
1793 let cos = c.cone().half_angle().cos();
1794 (fresh_range.0 * cos, fresh_range.1 * cos)
1795 }
1796 _ => fresh_range,
1797 };
1798 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1799 Ok(ogeom_geom::Line2d::over(
1800 ogeom_math::Axis2::new(
1801 ogeom_math::Point2::new(u, 0.0),
1802 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1803 ),
1804 rows.0 - 1.0,
1805 rows.1 + 1.0,
1806 )?
1807 .into())
1808 };
1809 ogeom_algo::attach_seam(
1810 model,
1811 &fresh,
1812 column(forward_col)?,
1813 column(reversed_col)?,
1814 new_surface,
1815 ogeom_topo::Location::identity(),
1816 rows,
1817 )?;
1818 } else {
1819 let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
1823 Some(exact) => exact,
1824 None => {
1825 let (fitted, _, _, worst_off, _) =
1826 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1827 &fresh_curve,
1828 fresh_range,
1829 &prep.surface,
1830 tol,
1831 )?;
1832 if worst_off > tol.confusion() {
1833 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1837 model.widen(&fresh, widened)?;
1838 if let Some((a, b)) = edge_vertices(model, &fresh)? {
1839 model.widen(&a, widened)?;
1840 model.widen(&b, widened)?;
1841 }
1842 }
1843 fitted
1844 }
1845 };
1846 ogeom_algo::attach_pcurve(
1847 model,
1848 &fresh,
1849 pcurve,
1850 new_surface,
1851 ogeom_topo::Location::identity(),
1852 fresh_range,
1853 )?;
1854 }
1855 }
1856 Ok(face)
1857}
1858
1859fn seam_end(
1864 model: &Model,
1865 faces: &[Shape],
1866 prepared: &[Prepared],
1867 vertex: &Shape,
1868 kept: &[usize],
1869 at: Point,
1870 tol: Tolerances,
1871) -> OgeomResult<Option<Point>> {
1872 use ogeom_geom::Curve2d as _;
1873 for (slot, &fi) in kept.iter().enumerate() {
1874 let other = kept[1 - slot];
1875 let face = &faces[fi];
1876 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1877 continue;
1878 };
1879 let old_surface = data.surface;
1880 let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1881 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1882 *uses.entry(e.node()).or_insert(0) += 1;
1883 }
1884 for e in explore_unique(model, face, ShapeType::Edge)? {
1885 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1886 continue;
1887 }
1888 let Some((a, b)) = edge_vertices(model, &e)? else {
1889 continue;
1890 };
1891 if a.node() != vertex.node() && b.node() != vertex.node() {
1892 continue;
1893 }
1894 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1895 continue;
1896 };
1897 let mut column = None;
1898 for repr in &edata.representations {
1899 if let EdgeRepr::Seam {
1900 forward,
1901 surface,
1902 range,
1903 ..
1904 } = repr
1905 && *surface == old_surface
1906 && let Some(pcurve) = model.geometry().pcurve(*forward)
1907 {
1908 column = Some(pcurve.point_at(range.0, tol)?.x);
1909 break;
1910 }
1911 }
1912 let Some(column) = column else {
1913 continue;
1914 };
1915 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1916 else {
1917 continue;
1918 };
1919 let found = ogeom_intersect::intersect_curve_surface(
1920 &iso,
1921 &prepared[other].surface,
1922 ogeom_intersect::CurveSurfaceOptions::default(),
1923 tol,
1924 )?;
1925 let nearest = found
1926 .crossings
1927 .iter()
1928 .map(|hit| hit.point)
1929 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1930 if let Some(p) = nearest {
1931 return Ok(Some(p));
1932 }
1933 }
1934 }
1935 Ok(None)
1936}
1937
1938fn rebuilt_lone_edge(
1945 model: &mut Model,
1946 edge: &Shape,
1947 sides: &[usize],
1948 constraint: &Displacement<'_>,
1949 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1950 tol: Tolerances,
1951) -> OgeomResult<Option<Shape>> {
1952 use ogeom_geom::Curve3d as _;
1953
1954 let (degenerate, curve) = {
1955 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1956 ogeom_bail!(Construction, "edge node holds no edge data");
1957 };
1958 let curve = data.curve3d().and_then(|repr| {
1959 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1960 return None;
1961 };
1962 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1963 });
1964 (data.degenerate, curve)
1965 };
1966 let Some((start, end)) = edge_vertices(model, edge)? else {
1967 ogeom_bail!(Construction, "a lone edge has no vertices");
1968 };
1969 if degenerate {
1970 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1972 ogeom_bail!(Construction, "an apex has no re-solved vertex");
1973 };
1974 let mut data = EdgeData::new();
1975 data.degenerate = true;
1976 return Ok(Some(
1977 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1978 ));
1979 }
1980 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1981 else {
1982 return Ok(None);
1983 };
1984 let circle = c.circle();
1985 let mut moved_points = Vec::with_capacity(3);
1986 for k in 0..3 {
1987 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1988 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1989 let p = Curve::Circle(c).point_at(t, tol)?;
1990 let Some((n, w)) = constraint(model, fi, p)? else {
1991 return Ok(None);
1992 };
1993 moved_points.push(p + n * w);
1994 }
1995 let centre = Point::from_vector(
1999 moved_points
2000 .iter()
2001 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
2002 / 3.0,
2003 );
2004 let radius = centre.distance(moved_points[0]);
2005 let reframed = ogeom_math::Circle::new(
2006 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
2007 radius,
2008 tol,
2009 )?;
2010 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
2011 Ok(Some(make_edge(model, moved, range, tol)?.shape))
2012}
2013
2014fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
2018 let mut a = [[0.0_f64; 3]; 3];
2020 let mut b = [0.0_f64; 3];
2021 for (n, w) in normals.iter().zip(amounts) {
2022 let row = [n.x, n.y, n.z];
2023 for i in 0..3 {
2024 for j in 0..3 {
2025 a[i][j] += row[i] * row[j];
2026 }
2027 b[i] += row[i] * w;
2028 }
2029 }
2030 if normals.len() == 2 {
2034 let along = normals[0].cross(normals[1]);
2035 let m = along.magnitude();
2036 if m <= tol.angular() {
2037 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2038 }
2039 let d = along / m;
2040 let row = [d.x, d.y, d.z];
2041 for i in 0..3 {
2042 for j in 0..3 {
2043 a[i][j] += row[i] * row[j];
2044 }
2045 }
2046 }
2047 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2048 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2049 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2050 if det.abs() <= tol.angular() * tol.angular() {
2051 ogeom_bail!(
2052 Construction,
2053 "a corner's faces are too nearly parallel to re-solve"
2054 );
2055 }
2056 let inv = |r: usize, c: usize| -> f64 {
2057 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2058 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2059 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2060 };
2061 let mut x = [0.0_f64; 3];
2062 for (i, xi) in x.iter_mut().enumerate() {
2063 for (j, bj) in b.iter().enumerate() {
2064 *xi += inv(i, j) * bj;
2065 }
2066 }
2067 Ok(Vector::new(x[0], x[1], x[2]))
2068}
2069
2070fn growing_edges(
2075 model: &Model,
2076 body: &Shape,
2077 held: &[TShapeId],
2078 convex: bool,
2079 tol: Tolerances,
2080) -> OgeomResult<Vec<Shape>> {
2081 use ogeom_geom::{Curve3d as _, Surface as _};
2082 let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
2085 std::collections::HashMap::new();
2086 for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2087 for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2088 holders.entry(e.node()).or_default().push((e, face.clone()));
2089 }
2090 }
2091 let mut out = Vec::new();
2092 for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2093 let mut faces: Vec<Shape> = Vec::new();
2094 for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2095 if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2096 faces.push(face.clone());
2097 }
2098 }
2099 let mut distinct: Vec<Shape> = Vec::new();
2100 for f in faces {
2101 if !distinct.iter().any(|d| d.node() == f.node()) {
2102 distinct.push(f);
2103 }
2104 }
2105 let [f1, f2] = distinct.as_slice() else {
2106 continue;
2107 };
2108 if held.contains(&f1.node()) || held.contains(&f2.node()) {
2109 continue;
2110 }
2111 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2112 continue;
2113 };
2114 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2115 continue;
2116 };
2117 let Some(geometry) = model.geometry().curve(*curve) else {
2118 continue;
2119 };
2120 let p = edge
2121 .transform(model.datums())?
2122 .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2123 let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2125 continue;
2126 };
2127 let Some(surface) = model.geometry().surface(face_data.surface) else {
2128 continue;
2129 };
2130 use ogeom_geom::Transformable as _;
2131 let placed = surface
2132 .clone()
2133 .transformed(&f1.transform(model.datums())?, tol)?;
2134 let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2135 let mut n1 = placed.normal_at(u, v, tol)?.vector();
2136 if f1.orientation() == Orientation::Reversed {
2137 n1 = -n1;
2138 }
2139 let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2142 let below = mesh
2143 .positions
2144 .iter()
2145 .map(|q| (*q - p).dot(n1))
2146 .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2147 .unwrap_or(0.0);
2148 if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2149 out.push(edge);
2150 }
2151 }
2152 Ok(out)
2153}