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_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 ogeom_core::FastMap;
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: FastMap<TShapeId, Point> = FastMap::default();
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 ogeom_core::collections::hash_map::Entry::Occupied(held) => {
77 if held.get().distance(at) > tol.confusion() {
78 instanced = true;
79 break 'outer;
80 }
81 }
82 ogeom_core::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: FastMap<TShapeId, SurfaceGeometry> = FastMap::default();
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: &ogeom_core::FastSet<TShapeId>,
272 built: Built,
273 tol: Tolerances,
274) -> OgeomResult<Built> {
275 let faces = explore_unique(model, solid, ShapeType::Face)?;
276 let mut corners: ogeom_core::FastSet<TShapeId> = ogeom_core::FastSet::default();
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<ogeom_core::FastSet<TShapeId>> {
315 if faces.is_empty() {
316 ogeom_bail!(Construction, "no face was named to move");
317 }
318 let own: ogeom_core::FastSet<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: FastMap<TShapeId, usize> = FastMap::default();
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::new(
751 sphere.frame(),
752 grown,
753 tol,
754 )?)
755 .into()
756 }
757 SurfaceGeometry::Torus(t) => {
758 let torus = t.torus();
759 let grown = sign.mul_add(amount, torus.minor_radius());
760 if grown <= tol.confusion() {
761 ogeom_bail!(Construction, "the offset consumes the torus's tube");
762 }
763 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
764 torus.frame(),
765 torus.major_radius(),
766 grown,
767 tol,
768 )?)
769 .into()
770 }
771 SurfaceGeometry::Cone(co) => {
772 let cone = co.cone();
773 let grown = (sign * amount / cone.half_angle().cos())
776 .mul_add(1.0, cone.reference_radius());
777 if grown <= tol.confusion() {
778 ogeom_bail!(Construction, "the offset consumes the cone's throat");
779 }
780 let (_, (v0, v1)) = surface.domain();
781 ogeom_geom::ConeSurface::new(
782 ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
783 (v0 - grow, v1 + grow),
784 )?
785 .into()
786 }
787 _ => ogeom_bail!(
788 Construction,
789 "offsetting a face on this surface needs a construction \
790 the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
791 ),
792 }
793 };
794 let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
798 prepared.push(Prepared {
799 shape: face.clone(),
800 surface: moved,
801 amount,
802 sign,
803 rings: if has_seam && closed_rings.len() == 2 && !fitted_support && !both_poles {
807 Some([closed_rings[0].clone(), closed_rings[1].clone()])
808 } else {
809 None
810 },
811 });
812 }
813
814 let mut edge_faces: FastMap<TShapeId, Vec<usize>> = FastMap::default();
816 for (fi, face) in faces.iter().enumerate() {
817 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
818 let entry = edge_faces.entry(e.node()).or_default();
819 if !entry.contains(&fi) {
820 entry.push(fi);
821 }
822 }
823 }
824
825 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
829 let face = &faces[fi];
830 let Some(node) = model.node(face) else {
831 ogeom_bail!(Dangling, "face is not in this model");
832 };
833 let NodeData::Face(data) = node.data() else {
834 ogeom_bail!(Construction, "face node holds no face data");
835 };
836 let Some(surface) = model.geometry().surface(data.surface) else {
837 ogeom_bail!(Dangling, "face refers to a surface not in this model");
838 };
839 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
840 if projection.distance > tol.confusion() * 100.0 {
841 return Ok(None);
842 }
843 let (u, v) = projection.parameters;
844 let (du, dv) = surface.d1_at(u, v, tol)?;
845 let n = du.cross(dv);
846 let m = n.magnitude();
847 if m <= tol.confusion() {
848 return Ok(None);
849 }
850 let outward = n / m * prepared[fi].sign;
851 Ok(Some((outward, prepared[fi].amount)))
852 };
853
854 let mut new_vertices: FastMap<TShapeId, (Shape, Point)> = FastMap::default();
858 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
859 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
860 continue;
861 };
862 let at = vertex.transform(model.datums())?.apply(data.point);
863 let mut seats: Vec<usize> = Vec::new();
864 for (fi, face) in faces.iter().enumerate() {
865 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
866 if v.node() == vertex.node() && !seats.contains(&fi) {
867 seats.push(fi);
868 }
869 }
870 }
871 if seats.is_empty() {
872 continue;
873 }
874 let mut normals: Vec<Vector> = Vec::new();
877 let mut amounts: Vec<f64> = Vec::new();
878 let mut kept: Vec<usize> = Vec::new();
879 for fi in &seats {
880 let Some((n, w)) = constraint(model, *fi, at)? else {
881 continue;
882 };
883 if let Some(k) = normals
884 .iter()
885 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
886 {
887 if (amounts[k] - w).abs() > tol.confusion() {
888 ogeom_bail!(
889 Construction,
890 "two tangent faces move a shared vertex by different \
891 amounts; the offset tears it"
892 );
893 }
894 continue;
895 }
896 normals.push(n);
897 amounts.push(w);
898 kept.push(*fi);
899 }
900 if normals.is_empty() {
901 let mut apex: Option<Point> = None;
905 for fi in &seats {
906 let Some(node) = model.node(&faces[*fi]) else {
907 continue;
908 };
909 let NodeData::Face(data) = node.data() else {
910 continue;
911 };
912 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
913 else {
914 continue;
915 };
916 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
917 continue;
918 }
919 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
920 apex = Some(moved_cone.cone().apex());
921 break;
922 }
923 }
924 if apex.is_none() {
929 for fi in &seats {
930 let Some(NodeData::Face(data)) = model.node(&faces[*fi]).map(|n| n.data())
931 else {
932 continue;
933 };
934 let Some(old @ SurfaceGeometry::Sphere(_)) =
935 model.geometry().surface(data.surface)
936 else {
937 continue;
938 };
939 if !matches!(prepared[*fi].surface, SurfaceGeometry::Sphere(_)) {
940 continue;
941 }
942 let local = faces[*fi].transform(model.datums())?;
943 let found =
944 ogeom_algo::project_on_surface(old, local.inverse()?.apply(at), 32, tol)?;
945 if found.distance > tol.confusion() * 100.0 {
946 continue;
947 }
948 let (u, v) = found.parameters;
949 apex = Some(local.apply(prepared[*fi].surface.point_at(u, v, tol)?));
950 break;
951 }
952 }
953 let Some(moved) = apex else {
954 ogeom_bail!(
955 Construction,
956 "a vertex with no seat the rebuild can read cannot be \
957 re-solved"
958 );
959 };
960 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
961 continue;
962 }
963 if normals.len() == 1 {
964 let moved = at + normals[0] * amounts[0];
969 corner_met(&prepared, &kept, moved, tol)?;
970 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
971 continue;
972 }
973 if kept.len() == 2
979 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
980 {
981 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
982 continue;
983 }
984 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
985 for _ in 0..8 {
989 let mut ns: Vec<Vector> = Vec::new();
990 let mut rs: Vec<f64> = Vec::new();
991 for fi in &kept {
992 let projection =
993 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
994 let (u, v) = projection.parameters;
995 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
996 let n = du.cross(dv);
997 let m = n.magnitude();
998 if m <= tol.confusion() {
999 continue;
1000 }
1001 let n = n / m;
1002 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
1003 ns.push(n);
1004 rs.push((moved - foot).dot(n));
1005 }
1006 if ns.len() < 2 {
1007 break;
1008 }
1009 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
1010 if worst <= tol.confusion() * 0.1 {
1011 break;
1012 }
1013 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
1014 moved += solve_corner(&ns, &step, tol)?;
1015 }
1016 corner_met(&prepared, &kept, moved, tol)?;
1017 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1018 }
1019
1020 let mut edge_uses: FastMap<TShapeId, usize> = FastMap::default();
1023 for face in &faces {
1024 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1025 *edge_uses.entry(e.node()).or_insert(0) += 1;
1026 }
1027 }
1028
1029 let mut new_edges: FastMap<TShapeId, Shape> = FastMap::default();
1031 let mut history = History::new();
1032 for edge in explore_unique(model, solid, ShapeType::Edge)? {
1033 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
1034 if sides.len() != 2 {
1035 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
1036 if let [fi] = sides.as_slice()
1041 && let Some(built) =
1042 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
1043 {
1044 history.modify(&edge, built.clone());
1045 new_edges.insert(edge.node(), built);
1046 }
1047 continue;
1048 }
1049 let Some(built) =
1052 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
1053 else {
1054 ogeom_bail!(
1055 Construction,
1056 "an edge with one face is neither a ring nor an apex; \
1057 the offset cannot re-derive it"
1058 );
1059 };
1060 history.modify(&edge, built.clone());
1061 new_edges.insert(edge.node(), built);
1062 continue;
1063 }
1064 let (curve, range) = {
1065 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1066 ogeom_bail!(Construction, "edge node holds no edge data");
1067 };
1068 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1069 ogeom_bail!(Construction, "an edge has no curve to offset");
1070 };
1071 let Some(geometry) = model.geometry().curve(*curve) else {
1072 ogeom_bail!(Dangling, "curve is not in this model");
1073 };
1074 (geometry.clone(), *range)
1075 };
1076 let forward = if edge.orientation() == Orientation::Reversed {
1077 edge.reversed()
1078 } else {
1079 edge.clone()
1080 };
1081 let built = match &curve {
1082 Curve::Line(_) => {
1083 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1090 ogeom_bail!(Construction, "a straight edge has no vertices");
1091 };
1092 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1093 new_vertices.get(&sv.node()).cloned(),
1094 new_vertices.get(&ev.node()).cloned(),
1095 ) else {
1096 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1097 };
1098 if p_to.distance(p_from) <= tol.parametric() {
1099 ogeom_bail!(Construction, "the offset collapses an edge");
1100 }
1101 let segment = LineCurve::segment(p_from, p_to, tol)?;
1102 let (t0, t1) = segment.domain();
1103 let moved: Curve = segment.into();
1104 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1105 }
1106 Curve::Circle(c)
1107 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
1108 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
1109 {
1110 let circle = c.circle();
1115 let found = ogeom_intersect::intersect_surfaces(
1116 &prepared[sides[0]].surface,
1117 &prepared[sides[1]].surface,
1118 ogeom_intersect::IntersectOptions::default(),
1119 tol,
1120 )?;
1121 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1122 ogeom_bail!(
1123 Construction,
1124 "the moved faces no longer meet along the edge they \
1125 shared; the offset collapses it"
1126 );
1127 };
1128 let mut best: Option<(ogeom_math::Circle, f64)> = None;
1129 for section in &candidates {
1130 let Curve::Circle(cc) = §ion.curve else {
1131 continue;
1132 };
1133 let candidate = cc.circle();
1134 let score = candidate.centre().distance(circle.centre())
1135 + (candidate.radius() - circle.radius()).abs();
1136 if best.as_ref().is_none_or(|(_, held)| score < *held) {
1137 best = Some((candidate, score));
1138 }
1139 }
1140 let Some((candidate, _)) = best else {
1141 ogeom_bail!(
1142 Construction,
1143 "the moved faces meet along nothing circular where a \
1144 circle was; the offset needs the general rebuild"
1145 );
1146 };
1147 let reframed = ogeom_math::Circle::new(
1148 Frame::new(
1149 candidate.centre(),
1150 circle.frame().z(),
1151 circle.frame().x(),
1152 tol,
1153 )?,
1154 candidate.radius(),
1155 tol,
1156 )?;
1157 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1158 let closed = {
1159 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1160 ogeom_bail!(Construction, "a ring has no vertex");
1161 };
1162 sv.node() == ev.node()
1163 };
1164 if closed {
1165 make_edge(model, moved, range, tol)?.shape
1166 } else {
1167 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1168 ogeom_bail!(Construction, "an arc has no vertices");
1169 };
1170 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1171 new_vertices.get(&sv.node()).cloned(),
1172 new_vertices.get(&ev.node()).cloned(),
1173 ) else {
1174 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
1175 };
1176 let angle_of = |p: Point| {
1177 let l = reframed.frame().to_local(p);
1178 l.y.atan2(l.x)
1179 };
1180 let tau = core::f64::consts::TAU;
1181 let mut t0 = angle_of(p_from);
1182 let mut t1 = angle_of(p_to);
1183 while t0 < range.0 - core::f64::consts::PI {
1185 t0 += tau;
1186 }
1187 while t0 > range.0 + core::f64::consts::PI {
1188 t0 -= tau;
1189 }
1190 while t1 <= t0 + tol.parametric() {
1191 t1 += tau;
1192 }
1193 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
1194 t1 -= tau;
1195 }
1196 if t1 <= t0 + tol.parametric() {
1197 ogeom_bail!(Construction, "the offset collapses an arc");
1198 }
1199 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1200 }
1201 }
1202 _ => {
1203 let unmoved = {
1208 let mut worst = 0.0_f64;
1209 'probe: for i in 0..9 {
1210 #[allow(clippy::cast_precision_loss)]
1211 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
1212 let p = curve.point_at(t, tol)?;
1213 for side in [sides[0], sides[1]] {
1214 let Ok(near) =
1215 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
1216 else {
1217 worst = f64::INFINITY;
1218 break 'probe;
1219 };
1220 worst = worst.max(near.distance);
1221 }
1222 }
1223 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
1227 };
1228 if let Some(worst) = unmoved {
1229 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1230 ogeom_bail!(Construction, "an edge has no vertices");
1231 };
1232 let closed = sv.node() == ev.node();
1233 let built = if closed {
1234 match new_vertices.get(&sv.node()).cloned() {
1237 Some((v_at, p_at)) => {
1238 let gap = curve.point_at(range.0, tol)?.distance(p_at);
1239 if gap > tol.confusion() {
1240 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1241 }
1242 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
1243 .shape
1244 }
1245 None => make_edge(model, curve.clone(), range, tol)?.shape,
1246 }
1247 } else {
1248 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1249 new_vertices.get(&sv.node()).cloned(),
1250 new_vertices.get(&ev.node()).cloned(),
1251 ) else {
1252 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1253 };
1254 let gap = curve
1258 .point_at(range.0, tol)?
1259 .distance(p_from)
1260 .min(curve.point_at(range.0, tol)?.distance(p_to))
1261 .max(
1262 curve
1263 .point_at(range.1, tol)?
1264 .distance(p_to)
1265 .min(curve.point_at(range.1, tol)?.distance(p_from)),
1266 );
1267 if gap > tol.confusion() {
1268 for v in [&v_from, &v_to] {
1269 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1270 }
1271 }
1272 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
1273 };
1274 if worst > tol.confusion() {
1275 model.widen(&built, ogeom_core::Tolerance::new(worst)?)?;
1276 }
1277 history.modify(&edge, built.clone());
1278 new_edges.insert(edge.node(), built);
1279 continue;
1280 }
1281 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1288 let found = ogeom_intersect::intersect_surfaces(
1289 &prepared[sides[0]].surface,
1290 &prepared[sides[1]].surface,
1291 ogeom_intersect::IntersectOptions::default(),
1292 tol,
1293 )?;
1294 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1295 ogeom_bail!(
1296 Construction,
1297 "the moved faces no longer meet along the edge they \
1298 shared; the offset collapses it"
1299 );
1300 };
1301 let mut best: Option<(Curve, f64, f64)> = None;
1302 for section in candidates {
1303 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
1304 else {
1305 continue;
1306 };
1307 if best
1308 .as_ref()
1309 .is_none_or(|(_, _, held)| projected.distance < *held)
1310 {
1311 best = Some((section.curve, section.tolerance, projected.distance));
1312 }
1313 }
1314 let Some((moved, slop, _)) = best else {
1315 ogeom_bail!(
1316 Construction,
1317 "the moved faces meet along nothing where the edge \
1318 was; the offset collapses it"
1319 );
1320 };
1321 let closed = {
1322 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1323 ogeom_bail!(Construction, "an edge has no vertices");
1324 };
1325 sv.node() == ev.node()
1326 };
1327 let built = if closed {
1328 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1334 ogeom_bail!(Construction, "a ring has no vertex");
1335 };
1336 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1337 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1338 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1339 let (lo, hi) = moved.domain();
1340 let seamed: Curve = if t.parameter > lo + tol.parametric()
1341 && t.parameter < hi - tol.parametric()
1342 {
1343 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1344 } else {
1345 moved.clone()
1346 };
1347 let seamed = {
1351 use ogeom_geom::Reversible as _;
1352 let (a, _) = seamed.domain();
1353 let old = curve.d1_at(range.0, tol)?;
1354 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1355 seamed.reversed()
1356 } else {
1357 seamed
1358 }
1359 };
1360 let miss = t.distance.max(slop);
1361 if miss > tol.confusion() {
1362 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1363 }
1364 let window = seamed.domain();
1365 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1366 }
1367 _ => {
1368 let window = moved.domain();
1369 make_edge(model, moved, window, tol)?.shape
1370 }
1371 }
1372 } else {
1373 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1374 ogeom_bail!(Construction, "an edge has no vertices");
1375 };
1376 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1377 new_vertices.get(&sv.node()).cloned(),
1378 new_vertices.get(&ev.node()).cloned(),
1379 ) else {
1380 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1381 };
1382 if slop > tol.confusion() {
1385 for v in [&v_from, &v_to] {
1386 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1387 }
1388 }
1389 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1390 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1391 if (tb - ta).abs() <= tol.parametric() {
1392 ogeom_bail!(Construction, "the offset collapses an edge");
1393 }
1394 let (moved, ta, tb) = if moved.is_periodic() {
1402 let along = moved.d1_at(ta, tol)?.dot(curve.d1_at(range.0, tol)?) >= 0.0;
1403 let (moved, ta, tb) = if along {
1404 (moved, ta, tb)
1405 } else {
1406 use ogeom_geom::Reversible as _;
1407 let (lo, hi) = moved.domain();
1408 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1409 };
1410 let (lo, hi) = moved.domain();
1411 let tb = ta + (tb - ta).rem_euclid(hi - lo);
1412 if tb - ta <= tol.parametric() {
1413 ogeom_bail!(Construction, "the offset collapses an edge");
1414 }
1415 (moved, ta, tb)
1416 } else if ta <= tb {
1417 (moved, ta, tb)
1418 } else {
1419 use ogeom_geom::Reversible as _;
1420 let (lo, hi) = moved.domain();
1421 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1422 };
1423 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1424 };
1425 if slop > tol.confusion() {
1426 model.widen(&built, ogeom_core::Tolerance::new(slop)?)?;
1427 }
1428 built
1429 }
1430 };
1431 history.modify(&edge, built.clone());
1432 new_edges.insert(edge.node(), built);
1433 }
1434
1435 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1438 for prep in &prepared {
1439 let built = if let Some(rings) = &prep.rings {
1440 let (Some(lo), Some(hi)) = (
1441 new_edges.get(&rings[0].node()),
1442 new_edges.get(&rings[1].node()),
1443 ) else {
1444 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1445 };
1446 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1447 if prep.shape.orientation() == Orientation::Reversed {
1448 band.reversed()
1449 } else {
1450 band
1451 }
1452 } else {
1453 let mut wires: Vec<Vec<Shape>> = Vec::new();
1454 let mut face_uses: FastMap<TShapeId, usize> = FastMap::default();
1455 let stored = prep.shape.oriented(Orientation::Forward);
1458 for wire in explore(model, &stored, Filter::OfType(ShapeType::Wire))? {
1459 let mut edges: Vec<Shape> = Vec::new();
1460 for used in model.ordered_children_of(&wire)? {
1463 *face_uses.entry(used.node()).or_insert(0) += 1;
1464 let Some(fresh) = new_edges.get(&used.node()) else {
1465 ogeom_bail!(Construction, "a face edge was not rebuilt");
1466 };
1467 edges.push(if used.orientation() == Orientation::Reversed {
1468 fresh.reversed()
1469 } else {
1470 fresh.clone()
1471 });
1472 }
1473 wires.push(edges);
1474 }
1475 let face = if face_uses.values().any(|c| *c >= 2) {
1476 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1481 } else {
1482 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1483 };
1484 if prep.shape.orientation() == Orientation::Reversed {
1485 face.reversed()
1486 } else {
1487 face
1488 }
1489 };
1490 history.modify(&prep.shape, built.clone());
1491 rebuilt_faces.push(built);
1492 }
1493
1494 let sewn = sew(model, &rebuilt_faces, tol)?;
1495 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1496 ogeom_bail!(Construction, "the offset solid did not close");
1497 }
1498 let outer = {
1502 let old_reversed = model
1503 .children_of(solid)?
1504 .first()
1505 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1506 if old_reversed {
1507 sewn.shells[0].reversed()
1508 } else {
1509 sewn.shells[0].clone()
1510 }
1511 };
1512 let offset = model.add_solid(std::slice::from_ref(&outer))?;
1515
1516 let size = ogeom_algo::shape_bounds(model, &offset, tol)?.diagonal();
1527 let fine = (size * 1e-5).clamp(
1528 tol.confusion() * 1e2,
1529 ogeom_mesh::Deflection::default().chord,
1530 );
1531 let mut mass = None;
1532 let mut first_error = None;
1533 for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1534 let deflection = ogeom_mesh::Deflection {
1535 chord,
1536 ..ogeom_mesh::Deflection::default()
1537 };
1538 match ogeom_algo::volume_properties(model, &offset, deflection, tol) {
1539 Ok(props) => {
1540 mass = Some(props.mass);
1541 break;
1542 }
1543 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1544 return Err(e);
1545 }
1546 Err(e) => {
1547 first_error.get_or_insert(e);
1548 }
1549 }
1550 }
1551 let Some(mass) = mass else {
1552 ogeom_bail!(
1553 Construction,
1554 "the offset solid's mesh does not close at any tried resolution{}",
1555 first_error.map_or_else(String::new, |e| format!(": {e}"))
1556 );
1557 };
1558 if !mass.is_finite() || mass <= tol.confusion() {
1559 ogeom_bail!(Construction, "the offset collapses the solid");
1560 }
1561
1562 history.modify(solid, offset.clone());
1563 Ok(Built::new(offset, history))
1564}
1565
1566fn rebuilt_seam_edge(
1575 model: &mut Model,
1576 edge: &Shape,
1577 prep: &Prepared,
1578 new_vertices: &FastMap<TShapeId, (Shape, Point)>,
1579 tol: Tolerances,
1580) -> OgeomResult<Option<Shape>> {
1581 use ogeom_geom::Curve2d as _;
1582
1583 let old_surface = {
1584 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1585 else {
1586 ogeom_bail!(Construction, "face node holds no face data");
1587 };
1588 data.surface
1589 };
1590 let found = {
1591 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1592 ogeom_bail!(Construction, "edge node holds no edge data");
1593 };
1594 let mut found = None;
1595 for repr in &data.representations {
1596 if let EdgeRepr::Seam {
1597 forward,
1598 surface,
1599 range,
1600 ..
1601 } = repr
1602 && *surface == old_surface
1603 {
1604 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1605 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1606 };
1607 found = Some(pcurve.point_at(range.0, tol)?.x);
1611 break;
1612 }
1613 }
1614 found
1615 };
1616 let Some(column) = found else {
1617 return Ok(None);
1618 };
1619 let forward = if edge.orientation() == Orientation::Reversed {
1623 edge.reversed()
1624 } else {
1625 edge.clone()
1626 };
1627 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1628 ogeom_bail!(Construction, "a seam has no vertices");
1629 };
1630 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1631 new_vertices.get(&sv.node()).cloned(),
1632 new_vertices.get(&ev.node()).cloned(),
1633 ) else {
1634 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1635 };
1636 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1637 ogeom_bail!(
1638 Construction,
1639 "the moved surface's iso-curve has no closed form; no seam can \
1640 be rebuilt"
1641 );
1642 };
1643 let along = |p: Point| -> OgeomResult<f64> {
1647 match &curve {
1648 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1649 Curve::Circle(c) => {
1650 let local = c.circle().frame().to_local(p);
1651 let mut angle = local.y.atan2(local.x);
1652 if angle < 0.0 {
1653 angle += core::f64::consts::TAU;
1654 }
1655 Ok(angle)
1656 }
1657 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1661 }
1662 };
1663 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1664 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1674 (tol.confusion() * 1e3).max(1e-4) * 4.0
1675 } else {
1676 tol.confusion() * 100.0
1677 };
1678 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1679 let off = curve.point_at(t, tol)?.distance(p);
1680 if off > slack {
1681 return Ok(None);
1682 }
1683 if off > tol.confusion() {
1686 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1687 }
1688 }
1689 Ok(Some(if t_start <= t_end {
1690 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1691 } else {
1692 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1696 .shape
1697 .reversed()
1698 }))
1699}
1700
1701fn assembled_with_seam(
1709 model: &mut Model,
1710 prep: &Prepared,
1711 wires: &[Vec<Shape>],
1712 new_edges: &FastMap<TShapeId, Shape>,
1713 tol: Tolerances,
1714) -> OgeomResult<Shape> {
1715 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1716 for edges in wires {
1717 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1718 }
1719 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1720 let new_surface = {
1721 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1722 ogeom_bail!(Construction, "the face just built holds no face data");
1723 };
1724 data.surface
1725 };
1726 let old_surface = {
1727 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1728 else {
1729 ogeom_bail!(Construction, "face node holds no face data");
1730 };
1731 data.surface
1732 };
1733
1734 let mut done: Vec<TShapeId> = Vec::new();
1735 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1736 if done.contains(&used.node()) {
1737 continue;
1738 }
1739 done.push(used.node());
1740 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1741 ogeom_bail!(Construction, "a face edge was not rebuilt");
1742 };
1743 if model
1746 .node(&fresh)
1747 .and_then(|n| n.data().as_edge())
1748 .is_some_and(|d| d.degenerate)
1749 {
1750 let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1751 .node(&used)
1752 .and_then(|n| n.data().as_edge())
1753 .and_then(|d| d.pcurve_for(old_surface, used.location()))
1754 .cloned()
1755 else {
1756 ogeom_bail!(Construction, "a pole has no row on its face");
1757 };
1758 let Some(row) = model.geometry().pcurve(curve).cloned() else {
1759 ogeom_bail!(Dangling, "a pole's row is not in this model");
1760 };
1761 ogeom_algo::attach_pcurve(
1762 model,
1763 &fresh,
1764 row,
1765 new_surface,
1766 ogeom_topo::Location::identity(),
1767 range,
1768 )?;
1769 continue;
1770 }
1771 let (fresh_curve, fresh_range) = {
1772 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1773 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1774 };
1775 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1776 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1777 };
1778 let Some(geometry) = model.geometry().curve(*curve) else {
1779 ogeom_bail!(Dangling, "curve is not in this model");
1780 };
1781 (geometry.clone(), *range)
1782 };
1783 let columns = {
1784 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1785 ogeom_bail!(Construction, "edge node holds no edge data");
1786 };
1787 let mut columns = None;
1788 for repr in &data.representations {
1789 if let EdgeRepr::Seam {
1790 forward,
1791 reversed,
1792 surface,
1793 range,
1794 ..
1795 } = repr
1796 && *surface == old_surface
1797 {
1798 use ogeom_geom::Curve2d as _;
1799 let (Some(f), Some(r)) = (
1800 model.geometry().pcurve(*forward),
1801 model.geometry().pcurve(*reversed),
1802 ) else {
1803 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1804 };
1805 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1806 break;
1807 }
1808 }
1809 columns
1810 };
1811 if let Some((forward_col, reversed_col)) = columns {
1812 let rows = match &prep.surface {
1815 SurfaceGeometry::Cone(c) => {
1816 let cos = c.cone().half_angle().cos();
1817 (fresh_range.0 * cos, fresh_range.1 * cos)
1818 }
1819 _ => fresh_range,
1820 };
1821 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1822 Ok(ogeom_geom::Line2d::over(
1823 ogeom_math::Axis2::new(
1824 ogeom_math::Point2::new(u, 0.0),
1825 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1826 ),
1827 rows.0 - 1.0,
1828 rows.1 + 1.0,
1829 )?
1830 .into())
1831 };
1832 ogeom_algo::attach_seam(
1833 model,
1834 &fresh,
1835 column(forward_col)?,
1836 column(reversed_col)?,
1837 new_surface,
1838 ogeom_topo::Location::identity(),
1839 rows,
1840 )?;
1841 } else {
1842 let pcurve = match ogeom_intersect::exact_pcurve_over(
1846 &fresh_curve,
1847 fresh_range,
1848 &prep.surface,
1849 tol,
1850 ) {
1851 Some(exact) => exact,
1852 None => {
1853 let (fitted, _, _, worst_off, _) =
1854 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1855 &fresh_curve,
1856 fresh_range,
1857 &prep.surface,
1858 tol,
1859 )?;
1860 if worst_off > tol.confusion() {
1861 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1865 model.widen(&fresh, widened)?;
1866 if let Some((a, b)) = edge_vertices(model, &fresh)? {
1867 model.widen(&a, widened)?;
1868 model.widen(&b, widened)?;
1869 }
1870 }
1871 fitted
1872 }
1873 };
1874 ogeom_algo::attach_pcurve(
1875 model,
1876 &fresh,
1877 pcurve,
1878 new_surface,
1879 ogeom_topo::Location::identity(),
1880 fresh_range,
1881 )?;
1882 }
1883 }
1884 Ok(face)
1885}
1886
1887fn seam_end(
1892 model: &Model,
1893 faces: &[Shape],
1894 prepared: &[Prepared],
1895 vertex: &Shape,
1896 kept: &[usize],
1897 at: Point,
1898 tol: Tolerances,
1899) -> OgeomResult<Option<Point>> {
1900 use ogeom_geom::Curve2d as _;
1901 for (slot, &fi) in kept.iter().enumerate() {
1902 let other = kept[1 - slot];
1903 let face = &faces[fi];
1904 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1905 continue;
1906 };
1907 let old_surface = data.surface;
1908 let mut uses: FastMap<TShapeId, usize> = FastMap::default();
1909 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1910 *uses.entry(e.node()).or_insert(0) += 1;
1911 }
1912 for e in explore_unique(model, face, ShapeType::Edge)? {
1913 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1914 continue;
1915 }
1916 let Some((a, b)) = edge_vertices(model, &e)? else {
1917 continue;
1918 };
1919 if a.node() != vertex.node() && b.node() != vertex.node() {
1920 continue;
1921 }
1922 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1923 continue;
1924 };
1925 let mut column = None;
1926 for repr in &edata.representations {
1927 if let EdgeRepr::Seam {
1928 forward,
1929 surface,
1930 range,
1931 ..
1932 } = repr
1933 && *surface == old_surface
1934 && let Some(pcurve) = model.geometry().pcurve(*forward)
1935 {
1936 column = Some(pcurve.point_at(range.0, tol)?.x);
1937 break;
1938 }
1939 }
1940 let Some(column) = column else {
1941 continue;
1942 };
1943 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1944 else {
1945 continue;
1946 };
1947 let found = ogeom_intersect::intersect_curve_surface(
1948 &iso,
1949 &prepared[other].surface,
1950 ogeom_intersect::CurveSurfaceOptions::default(),
1951 tol,
1952 )?;
1953 let nearest = found
1954 .crossings
1955 .iter()
1956 .map(|hit| hit.point)
1957 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1958 if let Some(p) = nearest {
1959 return Ok(Some(p));
1960 }
1961 }
1962 }
1963 Ok(None)
1964}
1965
1966fn rebuilt_lone_edge(
1973 model: &mut Model,
1974 edge: &Shape,
1975 sides: &[usize],
1976 constraint: &Displacement<'_>,
1977 new_vertices: &FastMap<TShapeId, (Shape, Point)>,
1978 tol: Tolerances,
1979) -> OgeomResult<Option<Shape>> {
1980 use ogeom_geom::Curve3d as _;
1981
1982 let (degenerate, curve) = {
1983 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1984 ogeom_bail!(Construction, "edge node holds no edge data");
1985 };
1986 let curve = data.curve3d().and_then(|repr| {
1987 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1988 return None;
1989 };
1990 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1991 });
1992 (data.degenerate, curve)
1993 };
1994 let Some((start, end)) = edge_vertices(model, edge)? else {
1995 ogeom_bail!(Construction, "a lone edge has no vertices");
1996 };
1997 if degenerate {
1998 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
2000 ogeom_bail!(Construction, "an apex has no re-solved vertex");
2001 };
2002 let mut data = EdgeData::new();
2003 data.degenerate = true;
2004 return Ok(Some(
2005 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
2006 ));
2007 }
2008 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
2009 else {
2010 return Ok(None);
2011 };
2012 let circle = c.circle();
2013 let mut moved_points = Vec::with_capacity(3);
2014 for k in 0..3 {
2015 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
2016 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
2017 let p = Curve::Circle(c).point_at(t, tol)?;
2018 let Some((n, w)) = constraint(model, fi, p)? else {
2019 return Ok(None);
2020 };
2021 moved_points.push(p + n * w);
2022 }
2023 let centre = Point::from_vector(
2027 moved_points
2028 .iter()
2029 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
2030 / 3.0,
2031 );
2032 let radius = centre.distance(moved_points[0]);
2033 let reframed = ogeom_math::Circle::new(
2034 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
2035 radius,
2036 tol,
2037 )?;
2038 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
2039 Ok(Some(make_edge(model, moved, range, tol)?.shape))
2040}
2041
2042fn corner_met(
2050 prepared: &[Prepared],
2051 seats: &[usize],
2052 at: Point,
2053 tol: Tolerances,
2054) -> OgeomResult<()> {
2055 for fi in seats {
2056 let off = match &prepared[*fi].surface {
2060 SurfaceGeometry::Plane(s) => s.plane().distance_to(at),
2061 SurfaceGeometry::Cylinder(s) => s.cylinder().distance_to(at),
2062 SurfaceGeometry::Cone(s) => s.cone().distance_to(at),
2063 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(at),
2064 SurfaceGeometry::Torus(s) => s.torus().distance_to(at),
2065 other => ogeom_algo::project_on_surface(other, at, 32, tol)?.distance,
2066 };
2067 if off > (tol.confusion() * 1e3).max(1e-4) {
2068 ogeom_bail!(
2069 Construction,
2070 "the moved faces no longer meet at a corner they shared; the \
2071 edit pulls them apart there"
2072 );
2073 }
2074 }
2075 Ok(())
2076}
2077
2078fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
2079 let mut a = [[0.0_f64; 3]; 3];
2081 let mut b = [0.0_f64; 3];
2082 for (n, w) in normals.iter().zip(amounts) {
2083 let row = [n.x, n.y, n.z];
2084 for i in 0..3 {
2085 for j in 0..3 {
2086 a[i][j] += row[i] * row[j];
2087 }
2088 b[i] += row[i] * w;
2089 }
2090 }
2091 if normals.len() == 2 {
2095 let along = normals[0].cross(normals[1]);
2096 let m = along.magnitude();
2097 if m <= tol.angular() {
2098 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2099 }
2100 let d = along / m;
2101 let row = [d.x, d.y, d.z];
2102 for i in 0..3 {
2103 for j in 0..3 {
2104 a[i][j] += row[i] * row[j];
2105 }
2106 }
2107 }
2108 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2109 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2110 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2111 if det.abs() <= tol.angular() * tol.angular() {
2112 ogeom_bail!(
2113 Construction,
2114 "a corner's faces are too nearly parallel to re-solve"
2115 );
2116 }
2117 let inv = |r: usize, c: usize| -> f64 {
2118 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2119 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2120 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2121 };
2122 let mut x = [0.0_f64; 3];
2123 for (i, xi) in x.iter_mut().enumerate() {
2124 for (j, bj) in b.iter().enumerate() {
2125 *xi += inv(i, j) * bj;
2126 }
2127 }
2128 Ok(Vector::new(x[0], x[1], x[2]))
2129}
2130
2131fn growing_edges(
2136 model: &Model,
2137 body: &Shape,
2138 held: &[TShapeId],
2139 convex: bool,
2140 tol: Tolerances,
2141) -> OgeomResult<Vec<Shape>> {
2142 use ogeom_geom::{Curve3d as _, Surface as _};
2143 let mut holders: ogeom_core::FastMap<TShapeId, Vec<(Shape, Shape)>> =
2146 ogeom_core::FastMap::default();
2147 for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2148 for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2149 holders.entry(e.node()).or_default().push((e, face.clone()));
2150 }
2151 }
2152 let mut out = Vec::new();
2153 for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2154 let mut faces: Vec<Shape> = Vec::new();
2155 for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2156 if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2157 faces.push(face.clone());
2158 }
2159 }
2160 let mut distinct: Vec<Shape> = Vec::new();
2161 for f in faces {
2162 if !distinct.iter().any(|d| d.node() == f.node()) {
2163 distinct.push(f);
2164 }
2165 }
2166 let [f1, f2] = distinct.as_slice() else {
2167 continue;
2168 };
2169 if held.contains(&f1.node()) || held.contains(&f2.node()) {
2170 continue;
2171 }
2172 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2173 continue;
2174 };
2175 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2176 continue;
2177 };
2178 let Some(geometry) = model.geometry().curve(*curve) else {
2179 continue;
2180 };
2181 let p = edge
2182 .transform(model.datums())?
2183 .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2184 let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2186 continue;
2187 };
2188 let Some(surface) = model.geometry().surface(face_data.surface) else {
2189 continue;
2190 };
2191 use ogeom_geom::Transformable as _;
2192 let placed = surface
2193 .clone()
2194 .transformed(&f1.transform(model.datums())?, tol)?;
2195 let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2196 let mut n1 = placed.normal_at(u, v, tol)?.vector();
2197 if f1.orientation() == Orientation::Reversed {
2198 n1 = -n1;
2199 }
2200 let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2203 let below = mesh
2204 .positions
2205 .iter()
2206 .map(|q| (*q - p).dot(n1))
2207 .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2208 .unwrap_or(0.0);
2209 if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2210 out.push(edge);
2211 }
2212 }
2213 Ok(out)
2214}