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 corner_met(&prepared, &kept, moved, tol)?;
967 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
968 continue;
969 }
970 if kept.len() == 2
976 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
977 {
978 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
979 continue;
980 }
981 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
982 for _ in 0..8 {
986 let mut ns: Vec<Vector> = Vec::new();
987 let mut rs: Vec<f64> = Vec::new();
988 for fi in &kept {
989 let projection =
990 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
991 let (u, v) = projection.parameters;
992 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
993 let n = du.cross(dv);
994 let m = n.magnitude();
995 if m <= tol.confusion() {
996 continue;
997 }
998 let n = n / m;
999 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
1000 ns.push(n);
1001 rs.push((moved - foot).dot(n));
1002 }
1003 if ns.len() < 2 {
1004 break;
1005 }
1006 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
1007 if worst <= tol.confusion() * 0.1 {
1008 break;
1009 }
1010 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
1011 moved += solve_corner(&ns, &step, tol)?;
1012 }
1013 corner_met(&prepared, &kept, moved, tol)?;
1014 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
1015 }
1016
1017 let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
1020 for face in &faces {
1021 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1022 *edge_uses.entry(e.node()).or_insert(0) += 1;
1023 }
1024 }
1025
1026 let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
1028 let mut history = History::new();
1029 for edge in explore_unique(model, solid, ShapeType::Edge)? {
1030 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
1031 if sides.len() != 2 {
1032 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
1033 if let [fi] = sides.as_slice()
1038 && let Some(built) =
1039 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
1040 {
1041 history.modify(&edge, built.clone());
1042 new_edges.insert(edge.node(), built);
1043 }
1044 continue;
1045 }
1046 let Some(built) =
1049 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
1050 else {
1051 ogeom_bail!(
1052 Construction,
1053 "an edge with one face is neither a ring nor an apex; \
1054 the offset cannot re-derive it"
1055 );
1056 };
1057 history.modify(&edge, built.clone());
1058 new_edges.insert(edge.node(), built);
1059 continue;
1060 }
1061 let (curve, range) = {
1062 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1063 ogeom_bail!(Construction, "edge node holds no edge data");
1064 };
1065 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1066 ogeom_bail!(Construction, "an edge has no curve to offset");
1067 };
1068 let Some(geometry) = model.geometry().curve(*curve) else {
1069 ogeom_bail!(Dangling, "curve is not in this model");
1070 };
1071 (geometry.clone(), *range)
1072 };
1073 let forward = if edge.orientation() == Orientation::Reversed {
1074 edge.reversed()
1075 } else {
1076 edge.clone()
1077 };
1078 let built = match &curve {
1079 Curve::Line(_) => {
1080 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1087 ogeom_bail!(Construction, "a straight edge has no vertices");
1088 };
1089 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1090 new_vertices.get(&sv.node()).cloned(),
1091 new_vertices.get(&ev.node()).cloned(),
1092 ) else {
1093 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1094 };
1095 if p_to.distance(p_from) <= tol.parametric() {
1096 ogeom_bail!(Construction, "the offset collapses an edge");
1097 }
1098 let segment = LineCurve::segment(p_from, p_to, tol)?;
1099 let (t0, t1) = segment.domain();
1100 let moved: Curve = segment.into();
1101 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1102 }
1103 Curve::Circle(c)
1104 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
1105 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
1106 {
1107 let circle = c.circle();
1112 let found = ogeom_intersect::intersect_surfaces(
1113 &prepared[sides[0]].surface,
1114 &prepared[sides[1]].surface,
1115 ogeom_intersect::IntersectOptions::default(),
1116 tol,
1117 )?;
1118 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1119 ogeom_bail!(
1120 Construction,
1121 "the moved faces no longer meet along the edge they \
1122 shared; the offset collapses it"
1123 );
1124 };
1125 let mut best: Option<(ogeom_math::Circle, f64)> = None;
1126 for section in &candidates {
1127 let Curve::Circle(cc) = §ion.curve else {
1128 continue;
1129 };
1130 let candidate = cc.circle();
1131 let score = candidate.centre().distance(circle.centre())
1132 + (candidate.radius() - circle.radius()).abs();
1133 if best.as_ref().is_none_or(|(_, held)| score < *held) {
1134 best = Some((candidate, score));
1135 }
1136 }
1137 let Some((candidate, _)) = best else {
1138 ogeom_bail!(
1139 Construction,
1140 "the moved faces meet along nothing circular where a \
1141 circle was; the offset needs the general rebuild"
1142 );
1143 };
1144 let reframed = ogeom_math::Circle::new(
1145 Frame::new(
1146 candidate.centre(),
1147 circle.frame().z(),
1148 circle.frame().x(),
1149 tol,
1150 )?,
1151 candidate.radius(),
1152 tol,
1153 )?;
1154 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1155 let closed = {
1156 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1157 ogeom_bail!(Construction, "a ring has no vertex");
1158 };
1159 sv.node() == ev.node()
1160 };
1161 if closed {
1162 make_edge(model, moved, range, tol)?.shape
1163 } else {
1164 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1165 ogeom_bail!(Construction, "an arc has no vertices");
1166 };
1167 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1168 new_vertices.get(&sv.node()).cloned(),
1169 new_vertices.get(&ev.node()).cloned(),
1170 ) else {
1171 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
1172 };
1173 let angle_of = |p: Point| {
1174 let l = reframed.frame().to_local(p);
1175 l.y.atan2(l.x)
1176 };
1177 let tau = core::f64::consts::TAU;
1178 let mut t0 = angle_of(p_from);
1179 let mut t1 = angle_of(p_to);
1180 while t0 < range.0 - core::f64::consts::PI {
1182 t0 += tau;
1183 }
1184 while t0 > range.0 + core::f64::consts::PI {
1185 t0 -= tau;
1186 }
1187 while t1 <= t0 + tol.parametric() {
1188 t1 += tau;
1189 }
1190 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
1191 t1 -= tau;
1192 }
1193 if t1 <= t0 + tol.parametric() {
1194 ogeom_bail!(Construction, "the offset collapses an arc");
1195 }
1196 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1197 }
1198 }
1199 _ => {
1200 let unmoved = {
1205 let mut worst = 0.0_f64;
1206 'probe: for i in 0..9 {
1207 #[allow(clippy::cast_precision_loss)]
1208 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
1209 let p = curve.point_at(t, tol)?;
1210 for side in [sides[0], sides[1]] {
1211 let Ok(near) =
1212 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
1213 else {
1214 worst = f64::INFINITY;
1215 break 'probe;
1216 };
1217 worst = worst.max(near.distance);
1218 }
1219 }
1220 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
1224 };
1225 if let Some(worst) = unmoved {
1226 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1227 ogeom_bail!(Construction, "an edge has no vertices");
1228 };
1229 let closed = sv.node() == ev.node();
1230 let built = if closed {
1231 match new_vertices.get(&sv.node()).cloned() {
1234 Some((v_at, p_at)) => {
1235 let gap = curve.point_at(range.0, tol)?.distance(p_at);
1236 if gap > tol.confusion() {
1237 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1238 }
1239 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
1240 .shape
1241 }
1242 None => make_edge(model, curve.clone(), range, tol)?.shape,
1243 }
1244 } else {
1245 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1246 new_vertices.get(&sv.node()).cloned(),
1247 new_vertices.get(&ev.node()).cloned(),
1248 ) else {
1249 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1250 };
1251 let gap = curve
1255 .point_at(range.0, tol)?
1256 .distance(p_from)
1257 .min(curve.point_at(range.0, tol)?.distance(p_to))
1258 .max(
1259 curve
1260 .point_at(range.1, tol)?
1261 .distance(p_to)
1262 .min(curve.point_at(range.1, tol)?.distance(p_from)),
1263 );
1264 if gap > tol.confusion() {
1265 for v in [&v_from, &v_to] {
1266 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1267 }
1268 }
1269 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
1270 };
1271 if worst > tol.confusion() {
1272 model.widen(&built, ogeom_core::Tolerance::new(worst)?)?;
1273 }
1274 history.modify(&edge, built.clone());
1275 new_edges.insert(edge.node(), built);
1276 continue;
1277 }
1278 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1285 let found = ogeom_intersect::intersect_surfaces(
1286 &prepared[sides[0]].surface,
1287 &prepared[sides[1]].surface,
1288 ogeom_intersect::IntersectOptions::default(),
1289 tol,
1290 )?;
1291 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1292 ogeom_bail!(
1293 Construction,
1294 "the moved faces no longer meet along the edge they \
1295 shared; the offset collapses it"
1296 );
1297 };
1298 let mut best: Option<(Curve, f64, f64)> = None;
1299 for section in candidates {
1300 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
1301 else {
1302 continue;
1303 };
1304 if best
1305 .as_ref()
1306 .is_none_or(|(_, _, held)| projected.distance < *held)
1307 {
1308 best = Some((section.curve, section.tolerance, projected.distance));
1309 }
1310 }
1311 let Some((moved, slop, _)) = best else {
1312 ogeom_bail!(
1313 Construction,
1314 "the moved faces meet along nothing where the edge \
1315 was; the offset collapses it"
1316 );
1317 };
1318 let closed = {
1319 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1320 ogeom_bail!(Construction, "an edge has no vertices");
1321 };
1322 sv.node() == ev.node()
1323 };
1324 let built = if closed {
1325 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1331 ogeom_bail!(Construction, "a ring has no vertex");
1332 };
1333 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1334 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1335 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1336 let (lo, hi) = moved.domain();
1337 let seamed: Curve = if t.parameter > lo + tol.parametric()
1338 && t.parameter < hi - tol.parametric()
1339 {
1340 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1341 } else {
1342 moved.clone()
1343 };
1344 let seamed = {
1348 use ogeom_geom::Reversible as _;
1349 let (a, _) = seamed.domain();
1350 let old = curve.d1_at(range.0, tol)?;
1351 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1352 seamed.reversed()
1353 } else {
1354 seamed
1355 }
1356 };
1357 let miss = t.distance.max(slop);
1358 if miss > tol.confusion() {
1359 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1360 }
1361 let window = seamed.domain();
1362 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1363 }
1364 _ => {
1365 let window = moved.domain();
1366 make_edge(model, moved, window, tol)?.shape
1367 }
1368 }
1369 } else {
1370 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1371 ogeom_bail!(Construction, "an edge has no vertices");
1372 };
1373 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1374 new_vertices.get(&sv.node()).cloned(),
1375 new_vertices.get(&ev.node()).cloned(),
1376 ) else {
1377 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1378 };
1379 if slop > tol.confusion() {
1382 for v in [&v_from, &v_to] {
1383 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1384 }
1385 }
1386 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1387 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1388 if (tb - ta).abs() <= tol.parametric() {
1389 ogeom_bail!(Construction, "the offset collapses an edge");
1390 }
1391 let (moved, ta, tb) = if ta <= tb {
1395 (moved, ta, tb)
1396 } else {
1397 use ogeom_geom::Reversible as _;
1398 let (lo, hi) = moved.domain();
1399 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1400 };
1401 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1402 };
1403 if slop > tol.confusion() {
1404 model.widen(&built, ogeom_core::Tolerance::new(slop)?)?;
1405 }
1406 built
1407 }
1408 };
1409 history.modify(&edge, built.clone());
1410 new_edges.insert(edge.node(), built);
1411 }
1412
1413 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1416 for prep in &prepared {
1417 let built = if let Some(rings) = &prep.rings {
1418 let (Some(lo), Some(hi)) = (
1419 new_edges.get(&rings[0].node()),
1420 new_edges.get(&rings[1].node()),
1421 ) else {
1422 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1423 };
1424 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1425 if prep.shape.orientation() == Orientation::Reversed {
1426 band.reversed()
1427 } else {
1428 band
1429 }
1430 } else {
1431 let mut wires: Vec<Vec<Shape>> = Vec::new();
1432 let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1433 let stored = prep.shape.oriented(Orientation::Forward);
1436 for wire in explore(model, &stored, Filter::OfType(ShapeType::Wire))? {
1437 let mut edges: Vec<Shape> = Vec::new();
1438 for used in model.ordered_children_of(&wire)? {
1441 *face_uses.entry(used.node()).or_insert(0) += 1;
1442 let Some(fresh) = new_edges.get(&used.node()) else {
1443 ogeom_bail!(Construction, "a face edge was not rebuilt");
1444 };
1445 edges.push(if used.orientation() == Orientation::Reversed {
1446 fresh.reversed()
1447 } else {
1448 fresh.clone()
1449 });
1450 }
1451 wires.push(edges);
1452 }
1453 let face = if face_uses.values().any(|c| *c >= 2) {
1454 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1459 } else {
1460 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1461 };
1462 if prep.shape.orientation() == Orientation::Reversed {
1463 face.reversed()
1464 } else {
1465 face
1466 }
1467 };
1468 history.modify(&prep.shape, built.clone());
1469 rebuilt_faces.push(built);
1470 }
1471
1472 let sewn = sew(model, &rebuilt_faces, tol)?;
1473 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1474 ogeom_bail!(Construction, "the offset solid did not close");
1475 }
1476 let outer = {
1480 let old_reversed = model
1481 .children_of(solid)?
1482 .first()
1483 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1484 if old_reversed {
1485 sewn.shells[0].reversed()
1486 } else {
1487 sewn.shells[0].clone()
1488 }
1489 };
1490 let built = make_solid(model, std::slice::from_ref(&outer))?;
1491
1492 let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1503 let fine = (size * 1e-5).clamp(
1504 tol.confusion() * 1e2,
1505 ogeom_mesh::Deflection::default().chord,
1506 );
1507 let mut mass = None;
1508 let mut first_error = None;
1509 for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1510 let deflection = ogeom_mesh::Deflection {
1511 chord,
1512 ..ogeom_mesh::Deflection::default()
1513 };
1514 match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1515 Ok(props) => {
1516 mass = Some(props.mass);
1517 break;
1518 }
1519 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1520 return Err(e);
1521 }
1522 Err(e) => {
1523 first_error.get_or_insert(e);
1524 }
1525 }
1526 }
1527 let Some(mass) = mass else {
1528 ogeom_bail!(
1529 Construction,
1530 "the offset solid's mesh does not close at any tried resolution{}",
1531 first_error.map_or_else(String::new, |e| format!(": {e}"))
1532 );
1533 };
1534 if !mass.is_finite() || mass <= tol.confusion() {
1535 ogeom_bail!(Construction, "the offset collapses the solid");
1536 }
1537
1538 history.modify(solid, built.shape.clone());
1539 Ok(Built::new(built.shape, history))
1540}
1541
1542fn rebuilt_seam_edge(
1551 model: &mut Model,
1552 edge: &Shape,
1553 prep: &Prepared,
1554 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1555 tol: Tolerances,
1556) -> OgeomResult<Option<Shape>> {
1557 use ogeom_geom::Curve2d as _;
1558
1559 let old_surface = {
1560 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1561 else {
1562 ogeom_bail!(Construction, "face node holds no face data");
1563 };
1564 data.surface
1565 };
1566 let found = {
1567 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1568 ogeom_bail!(Construction, "edge node holds no edge data");
1569 };
1570 let mut found = None;
1571 for repr in &data.representations {
1572 if let EdgeRepr::Seam {
1573 forward,
1574 surface,
1575 range,
1576 ..
1577 } = repr
1578 && *surface == old_surface
1579 {
1580 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1581 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1582 };
1583 found = Some(pcurve.point_at(range.0, tol)?.x);
1587 break;
1588 }
1589 }
1590 found
1591 };
1592 let Some(column) = found else {
1593 return Ok(None);
1594 };
1595 let forward = if edge.orientation() == Orientation::Reversed {
1599 edge.reversed()
1600 } else {
1601 edge.clone()
1602 };
1603 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1604 ogeom_bail!(Construction, "a seam has no vertices");
1605 };
1606 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1607 new_vertices.get(&sv.node()).cloned(),
1608 new_vertices.get(&ev.node()).cloned(),
1609 ) else {
1610 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1611 };
1612 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1613 ogeom_bail!(
1614 Construction,
1615 "the moved surface's iso-curve has no closed form; no seam can \
1616 be rebuilt"
1617 );
1618 };
1619 let along = |p: Point| -> OgeomResult<f64> {
1623 match &curve {
1624 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1625 Curve::Circle(c) => {
1626 let local = c.circle().frame().to_local(p);
1627 let mut angle = local.y.atan2(local.x);
1628 if angle < 0.0 {
1629 angle += core::f64::consts::TAU;
1630 }
1631 Ok(angle)
1632 }
1633 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1637 }
1638 };
1639 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1640 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1650 (tol.confusion() * 1e3).max(1e-4) * 4.0
1651 } else {
1652 tol.confusion() * 100.0
1653 };
1654 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1655 let off = curve.point_at(t, tol)?.distance(p);
1656 if off > slack {
1657 return Ok(None);
1658 }
1659 if off > tol.confusion() {
1662 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1663 }
1664 }
1665 Ok(Some(if t_start <= t_end {
1666 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1667 } else {
1668 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1672 .shape
1673 .reversed()
1674 }))
1675}
1676
1677fn assembled_with_seam(
1685 model: &mut Model,
1686 prep: &Prepared,
1687 wires: &[Vec<Shape>],
1688 new_edges: &HashMap<TShapeId, Shape>,
1689 tol: Tolerances,
1690) -> OgeomResult<Shape> {
1691 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1692 for edges in wires {
1693 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1694 }
1695 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1696 let new_surface = {
1697 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1698 ogeom_bail!(Construction, "the face just built holds no face data");
1699 };
1700 data.surface
1701 };
1702 let old_surface = {
1703 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1704 else {
1705 ogeom_bail!(Construction, "face node holds no face data");
1706 };
1707 data.surface
1708 };
1709
1710 let mut done: Vec<TShapeId> = Vec::new();
1711 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1712 if done.contains(&used.node()) {
1713 continue;
1714 }
1715 done.push(used.node());
1716 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1717 ogeom_bail!(Construction, "a face edge was not rebuilt");
1718 };
1719 if model
1722 .node(&fresh)
1723 .and_then(|n| n.data().as_edge())
1724 .is_some_and(|d| d.degenerate)
1725 {
1726 let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1727 .node(&used)
1728 .and_then(|n| n.data().as_edge())
1729 .and_then(|d| d.pcurve_for(old_surface, used.location()))
1730 .cloned()
1731 else {
1732 ogeom_bail!(Construction, "a pole has no row on its face");
1733 };
1734 let Some(row) = model.geometry().pcurve(curve).cloned() else {
1735 ogeom_bail!(Dangling, "a pole's row is not in this model");
1736 };
1737 ogeom_algo::attach_pcurve(
1738 model,
1739 &fresh,
1740 row,
1741 new_surface,
1742 ogeom_topo::Location::identity(),
1743 range,
1744 )?;
1745 continue;
1746 }
1747 let (fresh_curve, fresh_range) = {
1748 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1749 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1750 };
1751 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1752 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1753 };
1754 let Some(geometry) = model.geometry().curve(*curve) else {
1755 ogeom_bail!(Dangling, "curve is not in this model");
1756 };
1757 (geometry.clone(), *range)
1758 };
1759 let columns = {
1760 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1761 ogeom_bail!(Construction, "edge node holds no edge data");
1762 };
1763 let mut columns = None;
1764 for repr in &data.representations {
1765 if let EdgeRepr::Seam {
1766 forward,
1767 reversed,
1768 surface,
1769 range,
1770 ..
1771 } = repr
1772 && *surface == old_surface
1773 {
1774 use ogeom_geom::Curve2d as _;
1775 let (Some(f), Some(r)) = (
1776 model.geometry().pcurve(*forward),
1777 model.geometry().pcurve(*reversed),
1778 ) else {
1779 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1780 };
1781 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1782 break;
1783 }
1784 }
1785 columns
1786 };
1787 if let Some((forward_col, reversed_col)) = columns {
1788 let rows = match &prep.surface {
1791 SurfaceGeometry::Cone(c) => {
1792 let cos = c.cone().half_angle().cos();
1793 (fresh_range.0 * cos, fresh_range.1 * cos)
1794 }
1795 _ => fresh_range,
1796 };
1797 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1798 Ok(ogeom_geom::Line2d::over(
1799 ogeom_math::Axis2::new(
1800 ogeom_math::Point2::new(u, 0.0),
1801 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1802 ),
1803 rows.0 - 1.0,
1804 rows.1 + 1.0,
1805 )?
1806 .into())
1807 };
1808 ogeom_algo::attach_seam(
1809 model,
1810 &fresh,
1811 column(forward_col)?,
1812 column(reversed_col)?,
1813 new_surface,
1814 ogeom_topo::Location::identity(),
1815 rows,
1816 )?;
1817 } else {
1818 let pcurve = match ogeom_intersect::exact_pcurve_over(
1822 &fresh_curve,
1823 fresh_range,
1824 &prep.surface,
1825 tol,
1826 ) {
1827 Some(exact) => exact,
1828 None => {
1829 let (fitted, _, _, worst_off, _) =
1830 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1831 &fresh_curve,
1832 fresh_range,
1833 &prep.surface,
1834 tol,
1835 )?;
1836 if worst_off > tol.confusion() {
1837 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1841 model.widen(&fresh, widened)?;
1842 if let Some((a, b)) = edge_vertices(model, &fresh)? {
1843 model.widen(&a, widened)?;
1844 model.widen(&b, widened)?;
1845 }
1846 }
1847 fitted
1848 }
1849 };
1850 ogeom_algo::attach_pcurve(
1851 model,
1852 &fresh,
1853 pcurve,
1854 new_surface,
1855 ogeom_topo::Location::identity(),
1856 fresh_range,
1857 )?;
1858 }
1859 }
1860 Ok(face)
1861}
1862
1863fn seam_end(
1868 model: &Model,
1869 faces: &[Shape],
1870 prepared: &[Prepared],
1871 vertex: &Shape,
1872 kept: &[usize],
1873 at: Point,
1874 tol: Tolerances,
1875) -> OgeomResult<Option<Point>> {
1876 use ogeom_geom::Curve2d as _;
1877 for (slot, &fi) in kept.iter().enumerate() {
1878 let other = kept[1 - slot];
1879 let face = &faces[fi];
1880 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1881 continue;
1882 };
1883 let old_surface = data.surface;
1884 let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1885 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1886 *uses.entry(e.node()).or_insert(0) += 1;
1887 }
1888 for e in explore_unique(model, face, ShapeType::Edge)? {
1889 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1890 continue;
1891 }
1892 let Some((a, b)) = edge_vertices(model, &e)? else {
1893 continue;
1894 };
1895 if a.node() != vertex.node() && b.node() != vertex.node() {
1896 continue;
1897 }
1898 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1899 continue;
1900 };
1901 let mut column = None;
1902 for repr in &edata.representations {
1903 if let EdgeRepr::Seam {
1904 forward,
1905 surface,
1906 range,
1907 ..
1908 } = repr
1909 && *surface == old_surface
1910 && let Some(pcurve) = model.geometry().pcurve(*forward)
1911 {
1912 column = Some(pcurve.point_at(range.0, tol)?.x);
1913 break;
1914 }
1915 }
1916 let Some(column) = column else {
1917 continue;
1918 };
1919 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1920 else {
1921 continue;
1922 };
1923 let found = ogeom_intersect::intersect_curve_surface(
1924 &iso,
1925 &prepared[other].surface,
1926 ogeom_intersect::CurveSurfaceOptions::default(),
1927 tol,
1928 )?;
1929 let nearest = found
1930 .crossings
1931 .iter()
1932 .map(|hit| hit.point)
1933 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1934 if let Some(p) = nearest {
1935 return Ok(Some(p));
1936 }
1937 }
1938 }
1939 Ok(None)
1940}
1941
1942fn rebuilt_lone_edge(
1949 model: &mut Model,
1950 edge: &Shape,
1951 sides: &[usize],
1952 constraint: &Displacement<'_>,
1953 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1954 tol: Tolerances,
1955) -> OgeomResult<Option<Shape>> {
1956 use ogeom_geom::Curve3d as _;
1957
1958 let (degenerate, curve) = {
1959 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1960 ogeom_bail!(Construction, "edge node holds no edge data");
1961 };
1962 let curve = data.curve3d().and_then(|repr| {
1963 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1964 return None;
1965 };
1966 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1967 });
1968 (data.degenerate, curve)
1969 };
1970 let Some((start, end)) = edge_vertices(model, edge)? else {
1971 ogeom_bail!(Construction, "a lone edge has no vertices");
1972 };
1973 if degenerate {
1974 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1976 ogeom_bail!(Construction, "an apex has no re-solved vertex");
1977 };
1978 let mut data = EdgeData::new();
1979 data.degenerate = true;
1980 return Ok(Some(
1981 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1982 ));
1983 }
1984 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1985 else {
1986 return Ok(None);
1987 };
1988 let circle = c.circle();
1989 let mut moved_points = Vec::with_capacity(3);
1990 for k in 0..3 {
1991 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1992 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1993 let p = Curve::Circle(c).point_at(t, tol)?;
1994 let Some((n, w)) = constraint(model, fi, p)? else {
1995 return Ok(None);
1996 };
1997 moved_points.push(p + n * w);
1998 }
1999 let centre = Point::from_vector(
2003 moved_points
2004 .iter()
2005 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
2006 / 3.0,
2007 );
2008 let radius = centre.distance(moved_points[0]);
2009 let reframed = ogeom_math::Circle::new(
2010 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
2011 radius,
2012 tol,
2013 )?;
2014 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
2015 Ok(Some(make_edge(model, moved, range, tol)?.shape))
2016}
2017
2018fn corner_met(
2026 prepared: &[Prepared],
2027 seats: &[usize],
2028 at: Point,
2029 tol: Tolerances,
2030) -> OgeomResult<()> {
2031 for fi in seats {
2032 let off = match &prepared[*fi].surface {
2036 SurfaceGeometry::Plane(s) => s.plane().distance_to(at),
2037 SurfaceGeometry::Cylinder(s) => s.cylinder().distance_to(at),
2038 SurfaceGeometry::Cone(s) => s.cone().distance_to(at),
2039 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(at),
2040 SurfaceGeometry::Torus(s) => s.torus().distance_to(at),
2041 other => ogeom_algo::project_on_surface(other, at, 32, tol)?.distance,
2042 };
2043 if off > (tol.confusion() * 1e3).max(1e-4) {
2044 ogeom_bail!(
2045 Construction,
2046 "the moved faces no longer meet at a corner they shared; the \
2047 edit pulls them apart there"
2048 );
2049 }
2050 }
2051 Ok(())
2052}
2053
2054fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
2055 let mut a = [[0.0_f64; 3]; 3];
2057 let mut b = [0.0_f64; 3];
2058 for (n, w) in normals.iter().zip(amounts) {
2059 let row = [n.x, n.y, n.z];
2060 for i in 0..3 {
2061 for j in 0..3 {
2062 a[i][j] += row[i] * row[j];
2063 }
2064 b[i] += row[i] * w;
2065 }
2066 }
2067 if normals.len() == 2 {
2071 let along = normals[0].cross(normals[1]);
2072 let m = along.magnitude();
2073 if m <= tol.angular() {
2074 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2075 }
2076 let d = along / m;
2077 let row = [d.x, d.y, d.z];
2078 for i in 0..3 {
2079 for j in 0..3 {
2080 a[i][j] += row[i] * row[j];
2081 }
2082 }
2083 }
2084 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2085 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2086 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2087 if det.abs() <= tol.angular() * tol.angular() {
2088 ogeom_bail!(
2089 Construction,
2090 "a corner's faces are too nearly parallel to re-solve"
2091 );
2092 }
2093 let inv = |r: usize, c: usize| -> f64 {
2094 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2095 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2096 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2097 };
2098 let mut x = [0.0_f64; 3];
2099 for (i, xi) in x.iter_mut().enumerate() {
2100 for (j, bj) in b.iter().enumerate() {
2101 *xi += inv(i, j) * bj;
2102 }
2103 }
2104 Ok(Vector::new(x[0], x[1], x[2]))
2105}
2106
2107fn growing_edges(
2112 model: &Model,
2113 body: &Shape,
2114 held: &[TShapeId],
2115 convex: bool,
2116 tol: Tolerances,
2117) -> OgeomResult<Vec<Shape>> {
2118 use ogeom_geom::{Curve3d as _, Surface as _};
2119 let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
2122 std::collections::HashMap::new();
2123 for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2124 for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2125 holders.entry(e.node()).or_default().push((e, face.clone()));
2126 }
2127 }
2128 let mut out = Vec::new();
2129 for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2130 let mut faces: Vec<Shape> = Vec::new();
2131 for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2132 if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2133 faces.push(face.clone());
2134 }
2135 }
2136 let mut distinct: Vec<Shape> = Vec::new();
2137 for f in faces {
2138 if !distinct.iter().any(|d| d.node() == f.node()) {
2139 distinct.push(f);
2140 }
2141 }
2142 let [f1, f2] = distinct.as_slice() else {
2143 continue;
2144 };
2145 if held.contains(&f1.node()) || held.contains(&f2.node()) {
2146 continue;
2147 }
2148 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2149 continue;
2150 };
2151 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2152 continue;
2153 };
2154 let Some(geometry) = model.geometry().curve(*curve) else {
2155 continue;
2156 };
2157 let p = edge
2158 .transform(model.datums())?
2159 .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2160 let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2162 continue;
2163 };
2164 let Some(surface) = model.geometry().surface(face_data.surface) else {
2165 continue;
2166 };
2167 use ogeom_geom::Transformable as _;
2168 let placed = surface
2169 .clone()
2170 .transformed(&f1.transform(model.datums())?, tol)?;
2171 let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2172 let mut n1 = placed.normal_at(u, v, tol)?.vector();
2173 if f1.orientation() == Orientation::Reversed {
2174 n1 = -n1;
2175 }
2176 let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2179 let below = mesh
2180 .positions
2181 .iter()
2182 .map(|q| (*q - p).dot(n1))
2183 .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2184 .unwrap_or(0.0);
2185 if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2186 out.push(edge);
2187 }
2188 }
2189 Ok(out)
2190}