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(
57 model: &mut Model,
58 solid: &Shape,
59 handles: &[Shape],
60 tol: Tolerances,
61) -> OgeomResult<(Shape, Vec<Shape>, Option<ogeom_algo::History>)> {
62 let probe = Point::new(0.123_456_789, 9.87, -3.21);
63 let mut seen: HashMap<TShapeId, Point> = HashMap::new();
64 let mut instanced = false;
65 'outer: for kind in [ShapeType::Vertex, ShapeType::Edge] {
66 for occurrence in explore(model, solid, Filter::OfType(kind))? {
67 let at = occurrence.transform(model.datums())?.apply(probe);
68 match seen.entry(occurrence.node()) {
69 std::collections::hash_map::Entry::Occupied(held) => {
70 if held.get().distance(at) > tol.confusion() {
71 instanced = true;
72 break 'outer;
73 }
74 }
75 std::collections::hash_map::Entry::Vacant(slot) => {
76 slot.insert(at);
77 }
78 }
79 }
80 }
81 if !instanced {
82 return Ok((solid.clone(), handles.to_vec(), None));
83 }
84 let baked = ogeom_algo::baked_shape(model, solid, tol)?;
85 let mapped = handles
86 .iter()
87 .map(|h| match baked.history.trace(h) {
88 [one] => Ok(one.clone()),
89 traced => ogeom_bail!(
90 Construction,
91 "a face handle resolved to {} faces through the canonical \
92 rebuild; the reference is ambiguous",
93 traced.len()
94 ),
95 })
96 .collect::<OgeomResult<Vec<Shape>>>()?;
97 Ok((baked.shape, mapped, Some(baked.history)))
98}
99
100pub fn offset_shape(
109 model: &mut Model,
110 solid: &Shape,
111 offset: f64,
112 tol: Tolerances,
113) -> OgeomResult<Built> {
114 if !offset.is_finite() || offset.abs() <= tol.confusion() {
115 ogeom_bail!(Construction, "an offset of {offset} moves nothing");
116 }
117 let (canonical, _, prefix) = canonical_input(model, solid, &[], tol)?;
118 if let Some(prefix) = prefix {
119 let mut out = offset_shape(model, &canonical, offset, tol)?;
120 out.history = prefix.then(&out.history);
121 return Ok(out);
122 }
123 rebuilt(model, solid, &|_| offset, &|_| None, tol)
124}
125
126pub fn offset_faces(
141 model: &mut Model,
142 solid: &Shape,
143 faces: &[Shape],
144 distance: f64,
145 tol: Tolerances,
146) -> OgeomResult<Built> {
147 if !distance.is_finite() || distance.abs() <= tol.confusion() {
148 ogeom_bail!(Construction, "an offset of {distance} moves nothing");
149 }
150 let (canonical, mapped, prefix) = canonical_input(model, solid, faces, tol)?;
151 if let Some(prefix) = prefix {
152 let mut out = offset_faces(model, &canonical, &mapped, distance, tol)?;
153 out.history = prefix.then(&out.history);
154 return Ok(out);
155 }
156 let chosen = chosen_faces(model, solid, faces)?;
157 let built = rebuilt(
158 model,
159 solid,
160 &|face| {
161 if chosen.contains(&face.node()) {
162 distance
163 } else {
164 0.0
165 }
166 },
167 &|_| None,
168 tol,
169 )?;
170 still_sound(model, solid, &chosen, built, tol)
171}
172
173pub fn move_faces(
183 model: &mut Model,
184 solid: &Shape,
185 faces: &[Shape],
186 transform: &ogeom_math::Transform,
187 tol: Tolerances,
188) -> OgeomResult<Built> {
189 use ogeom_geom::Transformable as _;
190 use ogeom_math::TransformKind;
191 if !matches!(
192 transform.kind(),
193 TransformKind::Identity | TransformKind::Translation | TransformKind::Rotation
194 ) {
195 ogeom_bail!(
196 Construction,
197 "moving faces takes a translation or a rotation; a scale or a \
198 reflection reshapes them"
199 );
200 }
201 if transform.kind() == TransformKind::Identity {
202 ogeom_bail!(Construction, "the identity moves nothing");
203 }
204 let (canonical, mapped, prefix) = canonical_input(model, solid, faces, tol)?;
205 if let Some(prefix) = prefix {
206 let mut out = move_faces(model, &canonical, &mapped, transform, tol)?;
207 out.history = prefix.then(&out.history);
208 return Ok(out);
209 }
210 let chosen = chosen_faces(model, solid, faces)?;
211 let mut moved: HashMap<TShapeId, SurfaceGeometry> = HashMap::new();
214 for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
215 if !chosen.contains(&face.node()) || moved.contains_key(&face.node()) {
216 continue;
217 }
218 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
219 ogeom_bail!(Construction, "face node holds no face data");
220 };
221 let Some(surface) = model.geometry().surface(data.surface) else {
222 ogeom_bail!(Dangling, "face refers to a surface not in this model");
223 };
224 let placement = face.transform(model.datums())?;
225 let local = placement.inverse()? * *transform * placement;
226 moved.insert(face.node(), surface.transformed(&local, tol)?);
227 }
228 let built = rebuilt(
229 model,
230 solid,
231 &|_| 0.0,
232 &|face| moved.get(&face.node()).cloned(),
233 tol,
234 )?;
235 still_sound(model, solid, &chosen, built, tol)
236}
237
238fn still_sound(
245 model: &Model,
246 solid: &Shape,
247 chosen: &std::collections::HashSet<TShapeId>,
248 built: Built,
249 tol: Tolerances,
250) -> OgeomResult<Built> {
251 let faces = explore_unique(model, solid, ShapeType::Face)?;
252 let mut corners: std::collections::HashSet<TShapeId> = std::collections::HashSet::new();
253 for face in faces.iter().filter(|f| chosen.contains(&f.node())) {
254 for v in explore_unique(model, face, ShapeType::Vertex)? {
255 corners.insert(v.node());
256 }
257 }
258 let mut touched: Vec<Shape> = Vec::new();
259 for face in &faces {
260 let near = chosen.contains(&face.node())
261 || explore_unique(model, face, ShapeType::Vertex)?
262 .iter()
263 .any(|v| corners.contains(&v.node()));
264 if near {
265 touched.extend(built.history.trace(face).iter().cloned());
266 }
267 }
268 if !ogeom_algo::inside_out_faces(model, &built.shape, tol)?.is_empty() {
269 ogeom_bail!(
270 Construction,
271 "the moved faces run past the faces across from them and turn \
272 the solid inside out"
273 );
274 }
275 if !ogeom_algo::check_self_intersection_near(model, &built.shape, &touched, tol)?.is_empty() {
276 ogeom_bail!(
277 Construction,
278 "the moved faces run into the rest of the solid; the edit would \
279 make it cross itself"
280 );
281 }
282 Ok(built)
283}
284
285fn chosen_faces(
287 model: &Model,
288 solid: &Shape,
289 faces: &[Shape],
290) -> OgeomResult<std::collections::HashSet<TShapeId>> {
291 if faces.is_empty() {
292 ogeom_bail!(Construction, "no face was named to move");
293 }
294 let own: std::collections::HashSet<TShapeId> =
295 explore(model, solid, Filter::OfType(ShapeType::Face))?
296 .iter()
297 .map(Shape::node)
298 .collect();
299 for face in faces {
300 if !own.contains(&face.node()) {
301 ogeom_bail!(Construction, "a named face is not a face of the solid");
302 }
303 }
304 Ok(faces.iter().map(Shape::node).collect())
305}
306
307pub fn make_thick_solid(
328 model: &mut Model,
329 solid: &Shape,
330 removed: &[Shape],
331 thickness: f64,
332 tol: Tolerances,
333) -> OgeomResult<Built> {
334 make_thick_solid_with(model, solid, removed, thickness, Join::Intersection, tol)
335}
336
337pub fn make_thick_solid_with(
352 model: &mut Model,
353 solid: &Shape,
354 removed: &[Shape],
355 thickness: f64,
356 join: Join,
357 tol: Tolerances,
358) -> OgeomResult<Built> {
359 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
360 ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
361 }
362 let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
363 if let Some(prefix) = prefix {
364 let mut out = make_thick_solid_with(model, &canonical, &mapped, thickness, join, tol)?;
365 out.history = prefix.then(&out.history);
366 return Ok(out);
367 }
368 let outward_walls = thickness < 0.0;
371 let reach = thickness.abs();
372 let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
373 .iter()
374 .map(Shape::node)
375 .collect();
376 for face in removed {
377 if !own.contains(&face.node()) {
378 ogeom_bail!(Construction, "a removed face is not a face of the solid");
379 }
380 }
381
382 let mut tangent_opening = false;
383 for face in removed {
384 if has_tangent_neighbour(model, solid, face, tol)? {
385 tangent_opening = true;
386 break;
387 }
388 }
389 if !tangent_opening {
390 let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
391 let moved = rebuilt(
392 model,
393 solid,
394 &|face| {
395 if skip.contains(&face.node()) {
396 0.0
397 } else if outward_walls {
398 reach
399 } else {
400 -reach
401 }
402 },
403 &|_| None,
404 tol,
405 )?;
406 let moved = if join == Join::Arc {
410 let held: Vec<TShapeId> = removed
411 .iter()
412 .flat_map(|f| {
413 moved
414 .history
415 .modified(f)
416 .iter()
417 .map(Shape::node)
418 .collect::<Vec<_>>()
419 })
420 .collect();
421 let edges = growing_edges(model, &moved.shape, &held, outward_walls, tol)?;
422 if edges.is_empty() {
423 moved
424 } else {
425 ogeom_fillet::fillet_edges(model, &moved.shape, &edges, reach, tol)?
426 }
427 } else {
428 moved
429 };
430 let mut result = if outward_walls {
435 ogeom_bool::cut(model, &moved.shape, solid, tol)?
436 } else {
437 ogeom_bool::cut(model, solid, &moved.shape, tol)?
438 };
439 for face in removed {
440 result.history.delete(face);
441 }
442 return Ok(result);
443 }
444
445 if join == Join::Arc {
446 ogeom_bail!(
447 Construction,
448 "the arc join is built where every opening meets its neighbours \
449 across a corner; an opening with a tangent neighbour is not yet"
450 );
451 }
452 let displaced = if outward_walls { reach } else { -reach };
456 let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
457 let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
458 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
459 ogeom_bail!(Construction, "face node holds no face data");
460 };
461 let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
462 ogeom_bail!(
463 Construction,
464 "a tangent opening must be planar; a curved opening needs \
465 the general rebuild; see docs/PARITY.md, offset.shell-thicken"
466 );
467 };
468 let mut normal = p.plane().normal().vector();
469 if face.orientation() == Orientation::Reversed {
470 normal = -normal;
471 }
472 Ok(normal)
473 };
474 let mut result = if outward_walls {
475 let mut tool = solid.clone();
478 for face in removed {
479 let outward = opening_normal(model, face)?;
480 let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
481 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
482 }
483 ogeom_bool::cut(model, &moved.shape, &tool, tol)?
484 } else {
485 let mut tool = moved.shape.clone();
486 for face in removed {
487 let outward = opening_normal(model, face)?;
488 let [image] = moved.history.modified(face) else {
489 ogeom_bail!(Construction, "a removed face has no single cavity image");
490 };
491 let punch =
492 ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
493 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
494 }
495 ogeom_bool::cut(model, solid, &tool, tol)?
496 };
497 for face in removed {
498 result.history.delete(face);
499 }
500 Ok(result)
501}
502
503fn has_tangent_neighbour(
505 model: &Model,
506 solid: &Shape,
507 face: &Shape,
508 tol: Tolerances,
509) -> OgeomResult<bool> {
510 use ogeom_geom::Surface as _;
511
512 let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
513 .iter()
514 .map(Shape::node)
515 .collect();
516 let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
517 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
518 ogeom_bail!(Construction, "face node holds no face data");
519 };
520 let Some(surface) = model.geometry().surface(data.surface) else {
521 ogeom_bail!(Dangling, "face refers to a surface not in this model");
522 };
523 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
524 if projection.distance > tol.confusion() * 100.0 {
525 return Ok(None);
526 }
527 let (u, v) = projection.parameters;
528 let (du, dv) = surface.d1_at(u, v, tol)?;
529 let n = du.cross(dv);
530 let m = n.magnitude();
531 if m <= tol.confusion() {
532 return Ok(None);
533 }
534 Ok(Some(n / m))
535 };
536 for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
537 if other.node() == face.node() {
538 continue;
539 }
540 for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
541 if !own_edges.contains(&edge.node()) {
542 continue;
543 }
544 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
545 continue;
546 };
547 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
548 continue;
549 };
550 let Some(geometry) = model.geometry().curve(*curve) else {
551 continue;
552 };
553 let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
554 let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
555 else {
556 continue;
557 };
558 if a.cross(b).magnitude() <= 1e-6 {
559 return Ok(true);
560 }
561 }
562 }
563 Ok(false)
564}
565
566struct Prepared {
568 shape: Shape,
569 surface: SurfaceGeometry,
571 amount: f64,
573 sign: f64,
576 rings: Option<[Shape; 2]>,
578}
579
580pub(crate) fn rebuilt(
601 model: &mut Model,
602 solid: &Shape,
603 amount_of: &dyn Fn(&Shape) -> f64,
604 instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
605 tol: Tolerances,
606) -> OgeomResult<Built> {
607 use ogeom_geom::Surface as _;
608 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
609
610 let span = ogeom_algo::shape_bounds(model, solid, tol)?.diagonal();
612 let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
613 for face in &faces {
614 let amount = amount_of(face);
615 let Some(node) = model.node(face) else {
616 ogeom_bail!(Dangling, "face is not in this model");
617 };
618 let NodeData::Face(data) = node.data() else {
619 ogeom_bail!(Construction, "face node holds no face data");
620 };
621 let Some(surface) = model.geometry().surface(data.surface) else {
622 ogeom_bail!(Dangling, "face refers to a surface not in this model");
623 };
624 let sign = if face.orientation() == Orientation::Reversed {
625 -1.0
626 } else {
627 1.0
628 };
629 let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
630 let mut counts: HashMap<TShapeId, usize> = HashMap::new();
631 for e in &edges {
632 *counts.entry(e.node()).or_insert(0) += 1;
633 }
634 let has_seam = counts.values().any(|c| *c >= 2);
635 let closed_rings: Vec<Shape> = edges
636 .iter()
637 .filter(|e| {
638 edge_vertices(model, e)
639 .ok()
640 .flatten()
641 .is_some_and(|(a, b)| a.node() == b.node())
642 })
643 .cloned()
644 .collect();
645
646 let both_poles = closed_rings.len() == 2
647 && closed_rings.iter().all(|ring| {
648 model
649 .node(ring)
650 .and_then(|n| n.data().as_edge())
651 .is_some_and(|d| d.degenerate)
652 });
653 let grow = amount.abs() * 4.0 + 1.0;
654 let replacement = instead_of(face);
655 let moved: SurfaceGeometry = if let Some(given) = replacement {
656 given
657 } else if amount == 0.0 {
658 match surface {
665 SurfaceGeometry::Plane(p) => {
666 let ((u0, u1), (v0, v1)) = surface.domain();
667 PlaneSurface::over(p.plane(), (u0 - span, u1 + span), (v0 - span, v1 + span))?
668 .into()
669 }
670 SurfaceGeometry::Cylinder(c) => {
671 let (_, (v0, v1)) = surface.domain();
672 CylinderSurface::new(c.cylinder(), (v0 - span, v1 + span))?.into()
673 }
674 SurfaceGeometry::Cone(co) => {
675 let (_, (v0, v1)) = surface.domain();
678 let apex = co.apex_height();
679 let (lo, hi) = if apex <= v0 {
680 ((v0 - span).max(apex), v1 + span)
681 } else {
682 (v0 - span, (v1 + span).min(apex))
683 };
684 ogeom_geom::ConeSurface::new(co.cone(), (lo, hi))?.into()
685 }
686 _ => surface.clone(),
687 }
688 } else {
689 match surface {
690 SurfaceGeometry::Plane(p) => {
691 let plane = p.plane();
692 let ((u0, u1), (v0, v1)) = surface.domain();
693 let shifted = Plane::new(Frame::new(
694 plane.origin() + plane.normal().vector() * (sign * amount),
695 plane.normal(),
696 plane.frame().x(),
697 tol,
698 )?);
699 PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
700 .into()
701 }
702 SurfaceGeometry::Cylinder(c) => {
703 let cylinder = c.cylinder();
704 let grown = sign.mul_add(amount, cylinder.radius());
705 if grown <= tol.confusion() {
706 ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
707 }
708 let (_, (v0, v1)) = surface.domain();
709 CylinderSurface::new(
710 Cylinder::new(cylinder.frame(), grown, tol)?,
711 (v0 - grow, v1 + grow),
712 )?
713 .into()
714 }
715 SurfaceGeometry::Sphere(sp) => {
716 let sphere = sp.sphere();
717 let grown = sign.mul_add(amount, sphere.radius());
718 if grown <= tol.confusion() {
719 ogeom_bail!(Construction, "the offset consumes the sphere's radius");
720 }
721 ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
722 sphere.centre(),
723 grown,
724 tol,
725 )?)
726 .into()
727 }
728 SurfaceGeometry::Torus(t) => {
729 let torus = t.torus();
730 let grown = sign.mul_add(amount, torus.minor_radius());
731 if grown <= tol.confusion() {
732 ogeom_bail!(Construction, "the offset consumes the torus's tube");
733 }
734 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
735 torus.frame(),
736 torus.major_radius(),
737 grown,
738 tol,
739 )?)
740 .into()
741 }
742 SurfaceGeometry::Cone(co) => {
743 let cone = co.cone();
744 let grown = (sign * amount / cone.half_angle().cos())
747 .mul_add(1.0, cone.reference_radius());
748 if grown <= tol.confusion() {
749 ogeom_bail!(Construction, "the offset consumes the cone's throat");
750 }
751 let (_, (v0, v1)) = surface.domain();
752 ogeom_geom::ConeSurface::new(
753 ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
754 (v0 - grow, v1 + grow),
755 )?
756 .into()
757 }
758 _ => ogeom_bail!(
759 Construction,
760 "offsetting a face on this surface needs a construction \
761 the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
762 ),
763 }
764 };
765 let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
769 prepared.push(Prepared {
770 shape: face.clone(),
771 surface: moved,
772 amount,
773 sign,
774 rings: if has_seam && closed_rings.len() == 2 && !fitted_support && !both_poles {
778 Some([closed_rings[0].clone(), closed_rings[1].clone()])
779 } else {
780 None
781 },
782 });
783 }
784
785 let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
787 for (fi, face) in faces.iter().enumerate() {
788 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
789 let entry = edge_faces.entry(e.node()).or_default();
790 if !entry.contains(&fi) {
791 entry.push(fi);
792 }
793 }
794 }
795
796 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
800 let face = &faces[fi];
801 let Some(node) = model.node(face) else {
802 ogeom_bail!(Dangling, "face is not in this model");
803 };
804 let NodeData::Face(data) = node.data() else {
805 ogeom_bail!(Construction, "face node holds no face data");
806 };
807 let Some(surface) = model.geometry().surface(data.surface) else {
808 ogeom_bail!(Dangling, "face refers to a surface not in this model");
809 };
810 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
811 if projection.distance > tol.confusion() * 100.0 {
812 return Ok(None);
813 }
814 let (u, v) = projection.parameters;
815 let (du, dv) = surface.d1_at(u, v, tol)?;
816 let n = du.cross(dv);
817 let m = n.magnitude();
818 if m <= tol.confusion() {
819 return Ok(None);
820 }
821 let outward = n / m * prepared[fi].sign;
822 Ok(Some((outward, prepared[fi].amount)))
823 };
824
825 let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
829 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
830 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
831 continue;
832 };
833 let at = vertex.transform(model.datums())?.apply(data.point);
834 let mut seats: Vec<usize> = Vec::new();
835 for (fi, face) in faces.iter().enumerate() {
836 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
837 if v.node() == vertex.node() && !seats.contains(&fi) {
838 seats.push(fi);
839 }
840 }
841 }
842 if seats.is_empty() {
843 continue;
844 }
845 let mut normals: Vec<Vector> = Vec::new();
848 let mut amounts: Vec<f64> = Vec::new();
849 let mut kept: Vec<usize> = Vec::new();
850 for fi in &seats {
851 let Some((n, w)) = constraint(model, *fi, at)? else {
852 continue;
853 };
854 if let Some(k) = normals
855 .iter()
856 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
857 {
858 if (amounts[k] - w).abs() > tol.confusion() {
859 ogeom_bail!(
860 Construction,
861 "two tangent faces move a shared vertex by different \
862 amounts; the offset tears it"
863 );
864 }
865 continue;
866 }
867 normals.push(n);
868 amounts.push(w);
869 kept.push(*fi);
870 }
871 if normals.is_empty() {
872 let mut apex: Option<Point> = None;
876 for fi in &seats {
877 let Some(node) = model.node(&faces[*fi]) else {
878 continue;
879 };
880 let NodeData::Face(data) = node.data() else {
881 continue;
882 };
883 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
884 else {
885 continue;
886 };
887 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
888 continue;
889 }
890 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
891 apex = Some(moved_cone.cone().apex());
892 break;
893 }
894 }
895 if apex.is_none() {
900 for fi in &seats {
901 let Some(NodeData::Face(data)) = model.node(&faces[*fi]).map(|n| n.data())
902 else {
903 continue;
904 };
905 let Some(old @ SurfaceGeometry::Sphere(_)) =
906 model.geometry().surface(data.surface)
907 else {
908 continue;
909 };
910 if !matches!(prepared[*fi].surface, SurfaceGeometry::Sphere(_)) {
911 continue;
912 }
913 let local = faces[*fi].transform(model.datums())?;
914 let found =
915 ogeom_algo::project_on_surface(old, local.inverse()?.apply(at), 32, tol)?;
916 if found.distance > tol.confusion() * 100.0 {
917 continue;
918 }
919 let (u, v) = found.parameters;
920 apex = Some(local.apply(prepared[*fi].surface.point_at(u, v, tol)?));
921 break;
922 }
923 }
924 let Some(moved) = apex else {
925 ogeom_bail!(
926 Construction,
927 "a vertex with no seat the rebuild can read cannot be \
928 re-solved"
929 );
930 };
931 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
932 continue;
933 }
934 if normals.len() == 1 {
935 let moved = at + normals[0] * amounts[0];
940 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
941 continue;
942 }
943 if kept.len() == 2
949 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
950 {
951 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
952 continue;
953 }
954 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
955 for _ in 0..8 {
959 let mut ns: Vec<Vector> = Vec::new();
960 let mut rs: Vec<f64> = Vec::new();
961 for fi in &kept {
962 let projection =
963 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
964 let (u, v) = projection.parameters;
965 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
966 let n = du.cross(dv);
967 let m = n.magnitude();
968 if m <= tol.confusion() {
969 continue;
970 }
971 let n = n / m;
972 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
973 ns.push(n);
974 rs.push((moved - foot).dot(n));
975 }
976 if ns.len() < 2 {
977 break;
978 }
979 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
980 if worst <= tol.confusion() * 0.1 {
981 break;
982 }
983 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
984 moved += solve_corner(&ns, &step, tol)?;
985 }
986 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
987 }
988
989 let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
992 for face in &faces {
993 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
994 *edge_uses.entry(e.node()).or_insert(0) += 1;
995 }
996 }
997
998 let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
1000 let mut history = History::new();
1001 for edge in explore_unique(model, solid, ShapeType::Edge)? {
1002 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
1003 if sides.len() != 2 {
1004 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
1005 if let [fi] = sides.as_slice()
1010 && let Some(built) =
1011 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
1012 {
1013 history.modify(&edge, built.clone());
1014 new_edges.insert(edge.node(), built);
1015 }
1016 continue;
1017 }
1018 let Some(built) =
1021 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
1022 else {
1023 ogeom_bail!(
1024 Construction,
1025 "an edge with one face is neither a ring nor an apex; \
1026 the offset cannot re-derive it"
1027 );
1028 };
1029 history.modify(&edge, built.clone());
1030 new_edges.insert(edge.node(), built);
1031 continue;
1032 }
1033 let (curve, range) = {
1034 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1035 ogeom_bail!(Construction, "edge node holds no edge data");
1036 };
1037 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1038 ogeom_bail!(Construction, "an edge has no curve to offset");
1039 };
1040 let Some(geometry) = model.geometry().curve(*curve) else {
1041 ogeom_bail!(Dangling, "curve is not in this model");
1042 };
1043 (geometry.clone(), *range)
1044 };
1045 let forward = if edge.orientation() == Orientation::Reversed {
1046 edge.reversed()
1047 } else {
1048 edge.clone()
1049 };
1050 let built = match &curve {
1051 Curve::Line(_) => {
1052 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1059 ogeom_bail!(Construction, "a straight edge has no vertices");
1060 };
1061 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1062 new_vertices.get(&sv.node()).cloned(),
1063 new_vertices.get(&ev.node()).cloned(),
1064 ) else {
1065 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1066 };
1067 if p_to.distance(p_from) <= tol.parametric() {
1068 ogeom_bail!(Construction, "the offset collapses an edge");
1069 }
1070 let segment = LineCurve::segment(p_from, p_to, tol)?;
1071 let (t0, t1) = segment.domain();
1072 let moved: Curve = segment.into();
1073 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1074 }
1075 Curve::Circle(c)
1076 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
1077 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
1078 {
1079 let circle = c.circle();
1084 let found = ogeom_intersect::intersect_surfaces(
1085 &prepared[sides[0]].surface,
1086 &prepared[sides[1]].surface,
1087 ogeom_intersect::IntersectOptions::default(),
1088 tol,
1089 )?;
1090 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1091 ogeom_bail!(
1092 Construction,
1093 "the moved faces no longer meet along the edge they \
1094 shared; the offset collapses it"
1095 );
1096 };
1097 let mut best: Option<(ogeom_math::Circle, f64)> = None;
1098 for section in &candidates {
1099 let Curve::Circle(cc) = §ion.curve else {
1100 continue;
1101 };
1102 let candidate = cc.circle();
1103 let score = candidate.centre().distance(circle.centre())
1104 + (candidate.radius() - circle.radius()).abs();
1105 if best.as_ref().is_none_or(|(_, held)| score < *held) {
1106 best = Some((candidate, score));
1107 }
1108 }
1109 let Some((candidate, _)) = best else {
1110 ogeom_bail!(
1111 Construction,
1112 "the moved faces meet along nothing circular where a \
1113 circle was; the offset needs the general rebuild"
1114 );
1115 };
1116 let reframed = ogeom_math::Circle::new(
1117 Frame::new(
1118 candidate.centre(),
1119 circle.frame().z(),
1120 circle.frame().x(),
1121 tol,
1122 )?,
1123 candidate.radius(),
1124 tol,
1125 )?;
1126 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1127 let closed = {
1128 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1129 ogeom_bail!(Construction, "a ring has no vertex");
1130 };
1131 sv.node() == ev.node()
1132 };
1133 if closed {
1134 make_edge(model, moved, range, tol)?.shape
1135 } else {
1136 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1137 ogeom_bail!(Construction, "an arc has no vertices");
1138 };
1139 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1140 new_vertices.get(&sv.node()).cloned(),
1141 new_vertices.get(&ev.node()).cloned(),
1142 ) else {
1143 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
1144 };
1145 let angle_of = |p: Point| {
1146 let l = reframed.frame().to_local(p);
1147 l.y.atan2(l.x)
1148 };
1149 let tau = core::f64::consts::TAU;
1150 let mut t0 = angle_of(p_from);
1151 let mut t1 = angle_of(p_to);
1152 while t0 < range.0 - core::f64::consts::PI {
1154 t0 += tau;
1155 }
1156 while t0 > range.0 + core::f64::consts::PI {
1157 t0 -= tau;
1158 }
1159 while t1 <= t0 + tol.parametric() {
1160 t1 += tau;
1161 }
1162 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
1163 t1 -= tau;
1164 }
1165 if t1 <= t0 + tol.parametric() {
1166 ogeom_bail!(Construction, "the offset collapses an arc");
1167 }
1168 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
1169 }
1170 }
1171 _ => {
1172 let unmoved = {
1177 let mut worst = 0.0_f64;
1178 'probe: for i in 0..9 {
1179 #[allow(clippy::cast_precision_loss)]
1180 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
1181 let p = curve.point_at(t, tol)?;
1182 for side in [sides[0], sides[1]] {
1183 let Ok(near) =
1184 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
1185 else {
1186 worst = f64::INFINITY;
1187 break 'probe;
1188 };
1189 worst = worst.max(near.distance);
1190 }
1191 }
1192 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
1196 };
1197 if let Some(worst) = unmoved {
1198 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1199 ogeom_bail!(Construction, "an edge has no vertices");
1200 };
1201 let closed = sv.node() == ev.node();
1202 let built = if closed {
1203 match new_vertices.get(&sv.node()).cloned() {
1206 Some((v_at, p_at)) => {
1207 let gap = curve.point_at(range.0, tol)?.distance(p_at);
1208 if gap > tol.confusion() {
1209 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1210 }
1211 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
1212 .shape
1213 }
1214 None => make_edge(model, curve.clone(), range, tol)?.shape,
1215 }
1216 } else {
1217 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1218 new_vertices.get(&sv.node()).cloned(),
1219 new_vertices.get(&ev.node()).cloned(),
1220 ) else {
1221 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1222 };
1223 let gap = curve
1227 .point_at(range.0, tol)?
1228 .distance(p_from)
1229 .min(curve.point_at(range.0, tol)?.distance(p_to))
1230 .max(
1231 curve
1232 .point_at(range.1, tol)?
1233 .distance(p_to)
1234 .min(curve.point_at(range.1, tol)?.distance(p_from)),
1235 );
1236 if gap > tol.confusion() {
1237 for v in [&v_from, &v_to] {
1238 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
1239 }
1240 }
1241 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
1242 };
1243 if worst > tol.confusion()
1244 && let Some(node) = model.node_mut(&built)
1245 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1246 {
1247 data.tolerance = data.tolerance.widen_to(worst);
1248 }
1249 history.modify(&edge, built.clone());
1250 new_edges.insert(edge.node(), built);
1251 continue;
1252 }
1253 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1260 let found = ogeom_intersect::intersect_surfaces(
1261 &prepared[sides[0]].surface,
1262 &prepared[sides[1]].surface,
1263 ogeom_intersect::IntersectOptions::default(),
1264 tol,
1265 )?;
1266 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1267 ogeom_bail!(
1268 Construction,
1269 "the moved faces no longer meet along the edge they \
1270 shared; the offset collapses it"
1271 );
1272 };
1273 let mut best: Option<(Curve, f64, f64)> = None;
1274 for section in candidates {
1275 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
1276 else {
1277 continue;
1278 };
1279 if best
1280 .as_ref()
1281 .is_none_or(|(_, _, held)| projected.distance < *held)
1282 {
1283 best = Some((section.curve, section.tolerance, projected.distance));
1284 }
1285 }
1286 let Some((moved, slop, _)) = best else {
1287 ogeom_bail!(
1288 Construction,
1289 "the moved faces meet along nothing where the edge \
1290 was; the offset collapses it"
1291 );
1292 };
1293 let closed = {
1294 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1295 ogeom_bail!(Construction, "an edge has no vertices");
1296 };
1297 sv.node() == ev.node()
1298 };
1299 let built = if closed {
1300 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1306 ogeom_bail!(Construction, "a ring has no vertex");
1307 };
1308 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1309 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1310 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1311 let (lo, hi) = moved.domain();
1312 let seamed: Curve = if t.parameter > lo + tol.parametric()
1313 && t.parameter < hi - tol.parametric()
1314 {
1315 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1316 } else {
1317 moved.clone()
1318 };
1319 let seamed = {
1323 use ogeom_geom::Reversible as _;
1324 let (a, _) = seamed.domain();
1325 let old = curve.d1_at(range.0, tol)?;
1326 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1327 seamed.reversed()
1328 } else {
1329 seamed
1330 }
1331 };
1332 let miss = t.distance.max(slop);
1333 if miss > tol.confusion() {
1334 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1335 }
1336 let window = seamed.domain();
1337 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1338 }
1339 _ => {
1340 let window = moved.domain();
1341 make_edge(model, moved, window, tol)?.shape
1342 }
1343 }
1344 } else {
1345 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1346 ogeom_bail!(Construction, "an edge has no vertices");
1347 };
1348 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1349 new_vertices.get(&sv.node()).cloned(),
1350 new_vertices.get(&ev.node()).cloned(),
1351 ) else {
1352 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1353 };
1354 if slop > tol.confusion() {
1357 for v in [&v_from, &v_to] {
1358 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1359 }
1360 }
1361 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1362 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1363 if (tb - ta).abs() <= tol.parametric() {
1364 ogeom_bail!(Construction, "the offset collapses an edge");
1365 }
1366 let (moved, ta, tb) = if ta <= tb {
1370 (moved, ta, tb)
1371 } else {
1372 use ogeom_geom::Reversible as _;
1373 let (lo, hi) = moved.domain();
1374 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1375 };
1376 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1377 };
1378 if slop > tol.confusion()
1379 && let Some(node) = model.node_mut(&built)
1380 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1381 {
1382 data.tolerance = data.tolerance.widen_to(slop);
1383 }
1384 built
1385 }
1386 };
1387 history.modify(&edge, built.clone());
1388 new_edges.insert(edge.node(), built);
1389 }
1390
1391 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1394 for prep in &prepared {
1395 let built = if let Some(rings) = &prep.rings {
1396 let (Some(lo), Some(hi)) = (
1397 new_edges.get(&rings[0].node()),
1398 new_edges.get(&rings[1].node()),
1399 ) else {
1400 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1401 };
1402 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1403 if prep.shape.orientation() == Orientation::Reversed {
1404 band.reversed()
1405 } else {
1406 band
1407 }
1408 } else {
1409 let mut wires: Vec<Vec<Shape>> = Vec::new();
1410 let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1411 for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1412 let mut edges: Vec<Shape> = Vec::new();
1413 for used in model.ordered_children_of(&wire)? {
1416 *face_uses.entry(used.node()).or_insert(0) += 1;
1417 let Some(fresh) = new_edges.get(&used.node()) else {
1418 ogeom_bail!(Construction, "a face edge was not rebuilt");
1419 };
1420 edges.push(if used.orientation() == Orientation::Reversed {
1421 fresh.reversed()
1422 } else {
1423 fresh.clone()
1424 });
1425 }
1426 wires.push(edges);
1427 }
1428 let face = if face_uses.values().any(|c| *c >= 2) {
1429 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1434 } else {
1435 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1436 };
1437 if prep.shape.orientation() == Orientation::Reversed {
1438 face.reversed()
1439 } else {
1440 face
1441 }
1442 };
1443 history.modify(&prep.shape, built.clone());
1444 rebuilt_faces.push(built);
1445 }
1446
1447 let sewn = sew(model, &rebuilt_faces, tol)?;
1448 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1449 ogeom_bail!(Construction, "the offset solid did not close");
1450 }
1451 let outer = {
1455 let old_reversed = model
1456 .children_of(solid)?
1457 .first()
1458 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1459 if old_reversed {
1460 sewn.shells[0].reversed()
1461 } else {
1462 sewn.shells[0].clone()
1463 }
1464 };
1465 let built = make_solid(model, std::slice::from_ref(&outer))?;
1466
1467 let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1478 let fine = (size * 1e-5).clamp(
1479 tol.confusion() * 1e2,
1480 ogeom_mesh::Deflection::default().chord,
1481 );
1482 let mut mass = None;
1483 let mut first_error = None;
1484 for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1485 let deflection = ogeom_mesh::Deflection {
1486 chord,
1487 ..ogeom_mesh::Deflection::default()
1488 };
1489 match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1490 Ok(props) => {
1491 mass = Some(props.mass);
1492 break;
1493 }
1494 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1495 return Err(e);
1496 }
1497 Err(e) => {
1498 first_error.get_or_insert(e);
1499 }
1500 }
1501 }
1502 let Some(mass) = mass else {
1503 ogeom_bail!(
1504 Construction,
1505 "the offset solid's mesh does not close at any tried resolution{}",
1506 first_error.map_or_else(String::new, |e| format!(": {e}"))
1507 );
1508 };
1509 if !mass.is_finite() || mass <= tol.confusion() {
1510 ogeom_bail!(Construction, "the offset collapses the solid");
1511 }
1512
1513 history.modify(solid, built.shape.clone());
1514 Ok(Built::new(built.shape, history))
1515}
1516
1517fn rebuilt_seam_edge(
1529 model: &mut Model,
1530 edge: &Shape,
1531 prep: &Prepared,
1532 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1533 tol: Tolerances,
1534) -> OgeomResult<Option<Shape>> {
1535 use ogeom_geom::Curve2d as _;
1536
1537 let old_surface = {
1538 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1539 else {
1540 ogeom_bail!(Construction, "face node holds no face data");
1541 };
1542 data.surface
1543 };
1544 let found = {
1545 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1546 ogeom_bail!(Construction, "edge node holds no edge data");
1547 };
1548 let mut found = None;
1549 for repr in &data.representations {
1550 if let EdgeRepr::Seam {
1551 forward,
1552 surface,
1553 range,
1554 ..
1555 } = repr
1556 && *surface == old_surface
1557 {
1558 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1559 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1560 };
1561 found = Some(pcurve.point_at(range.0, tol)?.x);
1565 break;
1566 }
1567 }
1568 found
1569 };
1570 let Some(column) = found else {
1571 return Ok(None);
1572 };
1573 let forward = if edge.orientation() == Orientation::Reversed {
1577 edge.reversed()
1578 } else {
1579 edge.clone()
1580 };
1581 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1582 ogeom_bail!(Construction, "a seam has no vertices");
1583 };
1584 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1585 new_vertices.get(&sv.node()).cloned(),
1586 new_vertices.get(&ev.node()).cloned(),
1587 ) else {
1588 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1589 };
1590 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1591 ogeom_bail!(
1592 Construction,
1593 "the moved surface's iso-curve has no closed form; no seam can \
1594 be rebuilt"
1595 );
1596 };
1597 let along = |p: Point| -> OgeomResult<f64> {
1601 match &curve {
1602 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1603 Curve::Circle(c) => {
1604 let local = c.circle().frame().to_local(p);
1605 let mut angle = local.y.atan2(local.x);
1606 if angle < 0.0 {
1607 angle += core::f64::consts::TAU;
1608 }
1609 Ok(angle)
1610 }
1611 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1615 }
1616 };
1617 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1618 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1628 (tol.confusion() * 1e3).max(1e-4) * 4.0
1629 } else {
1630 tol.confusion() * 100.0
1631 };
1632 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1633 let off = curve.point_at(t, tol)?.distance(p);
1634 if off > slack {
1635 return Ok(None);
1636 }
1637 if off > tol.confusion() {
1640 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1641 }
1642 }
1643 Ok(Some(if t_start <= t_end {
1644 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1645 } else {
1646 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1650 .shape
1651 .reversed()
1652 }))
1653}
1654
1655fn assembled_with_seam(
1663 model: &mut Model,
1664 prep: &Prepared,
1665 wires: &[Vec<Shape>],
1666 new_edges: &HashMap<TShapeId, Shape>,
1667 tol: Tolerances,
1668) -> OgeomResult<Shape> {
1669 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1670 for edges in wires {
1671 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1672 }
1673 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1674 let new_surface = {
1675 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1676 ogeom_bail!(Construction, "the face just built holds no face data");
1677 };
1678 data.surface
1679 };
1680 let old_surface = {
1681 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1682 else {
1683 ogeom_bail!(Construction, "face node holds no face data");
1684 };
1685 data.surface
1686 };
1687
1688 let mut done: Vec<TShapeId> = Vec::new();
1689 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1690 if done.contains(&used.node()) {
1691 continue;
1692 }
1693 done.push(used.node());
1694 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1695 ogeom_bail!(Construction, "a face edge was not rebuilt");
1696 };
1697 if model
1700 .node(&fresh)
1701 .and_then(|n| n.data().as_edge())
1702 .is_some_and(|d| d.degenerate)
1703 {
1704 let Some(EdgeRepr::PCurve { curve, range, .. }) = model
1705 .node(&used)
1706 .and_then(|n| n.data().as_edge())
1707 .and_then(|d| d.pcurve_for(old_surface, used.location()))
1708 .cloned()
1709 else {
1710 ogeom_bail!(Construction, "a pole has no row on its face");
1711 };
1712 let Some(row) = model.geometry().pcurve(curve).cloned() else {
1713 ogeom_bail!(Dangling, "a pole's row is not in this model");
1714 };
1715 ogeom_algo::attach_pcurve(
1716 model,
1717 &fresh,
1718 row,
1719 new_surface,
1720 ogeom_topo::Location::identity(),
1721 range,
1722 )?;
1723 continue;
1724 }
1725 let (fresh_curve, fresh_range) = {
1726 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1727 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1728 };
1729 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1730 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1731 };
1732 let Some(geometry) = model.geometry().curve(*curve) else {
1733 ogeom_bail!(Dangling, "curve is not in this model");
1734 };
1735 (geometry.clone(), *range)
1736 };
1737 let columns = {
1738 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1739 ogeom_bail!(Construction, "edge node holds no edge data");
1740 };
1741 let mut columns = None;
1742 for repr in &data.representations {
1743 if let EdgeRepr::Seam {
1744 forward,
1745 reversed,
1746 surface,
1747 range,
1748 ..
1749 } = repr
1750 && *surface == old_surface
1751 {
1752 use ogeom_geom::Curve2d as _;
1753 let (Some(f), Some(r)) = (
1754 model.geometry().pcurve(*forward),
1755 model.geometry().pcurve(*reversed),
1756 ) else {
1757 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1758 };
1759 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1760 break;
1761 }
1762 }
1763 columns
1764 };
1765 if let Some((forward_col, reversed_col)) = columns {
1766 let rows = match &prep.surface {
1769 SurfaceGeometry::Cone(c) => {
1770 let cos = c.cone().half_angle().cos();
1771 (fresh_range.0 * cos, fresh_range.1 * cos)
1772 }
1773 _ => fresh_range,
1774 };
1775 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1776 Ok(ogeom_geom::Line2d::over(
1777 ogeom_math::Axis2::new(
1778 ogeom_math::Point2::new(u, 0.0),
1779 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1780 ),
1781 rows.0 - 1.0,
1782 rows.1 + 1.0,
1783 )?
1784 .into())
1785 };
1786 ogeom_algo::attach_seam(
1787 model,
1788 &fresh,
1789 column(forward_col)?,
1790 column(reversed_col)?,
1791 new_surface,
1792 ogeom_topo::Location::identity(),
1793 rows,
1794 )?;
1795 } else {
1796 let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
1800 Some(exact) => exact,
1801 None => {
1802 let (fitted, _, _, worst_off, _) =
1803 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1804 &fresh_curve,
1805 fresh_range,
1806 &prep.surface,
1807 tol,
1808 )?;
1809 if worst_off > tol.confusion() {
1810 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1814 model.widen(&fresh, widened)?;
1815 if let Some((a, b)) = edge_vertices(model, &fresh)? {
1816 model.widen(&a, widened)?;
1817 model.widen(&b, widened)?;
1818 }
1819 }
1820 fitted
1821 }
1822 };
1823 ogeom_algo::attach_pcurve(
1824 model,
1825 &fresh,
1826 pcurve,
1827 new_surface,
1828 ogeom_topo::Location::identity(),
1829 fresh_range,
1830 )?;
1831 }
1832 }
1833 Ok(face)
1834}
1835
1836fn seam_end(
1841 model: &Model,
1842 faces: &[Shape],
1843 prepared: &[Prepared],
1844 vertex: &Shape,
1845 kept: &[usize],
1846 at: Point,
1847 tol: Tolerances,
1848) -> OgeomResult<Option<Point>> {
1849 use ogeom_geom::Curve2d as _;
1850 for (slot, &fi) in kept.iter().enumerate() {
1851 let other = kept[1 - slot];
1852 let face = &faces[fi];
1853 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1854 continue;
1855 };
1856 let old_surface = data.surface;
1857 let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1858 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1859 *uses.entry(e.node()).or_insert(0) += 1;
1860 }
1861 for e in explore_unique(model, face, ShapeType::Edge)? {
1862 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1863 continue;
1864 }
1865 let Some((a, b)) = edge_vertices(model, &e)? else {
1866 continue;
1867 };
1868 if a.node() != vertex.node() && b.node() != vertex.node() {
1869 continue;
1870 }
1871 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1872 continue;
1873 };
1874 let mut column = None;
1875 for repr in &edata.representations {
1876 if let EdgeRepr::Seam {
1877 forward,
1878 surface,
1879 range,
1880 ..
1881 } = repr
1882 && *surface == old_surface
1883 && let Some(pcurve) = model.geometry().pcurve(*forward)
1884 {
1885 column = Some(pcurve.point_at(range.0, tol)?.x);
1886 break;
1887 }
1888 }
1889 let Some(column) = column else {
1890 continue;
1891 };
1892 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1893 else {
1894 continue;
1895 };
1896 let found = ogeom_intersect::intersect_curve_surface(
1897 &iso,
1898 &prepared[other].surface,
1899 ogeom_intersect::CurveSurfaceOptions::default(),
1900 tol,
1901 )?;
1902 let nearest = found
1903 .crossings
1904 .iter()
1905 .map(|hit| hit.point)
1906 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1907 if let Some(p) = nearest {
1908 return Ok(Some(p));
1909 }
1910 }
1911 }
1912 Ok(None)
1913}
1914
1915fn rebuilt_lone_edge(
1922 model: &mut Model,
1923 edge: &Shape,
1924 sides: &[usize],
1925 constraint: &Displacement<'_>,
1926 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1927 tol: Tolerances,
1928) -> OgeomResult<Option<Shape>> {
1929 use ogeom_geom::Curve3d as _;
1930
1931 let (degenerate, curve) = {
1932 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1933 ogeom_bail!(Construction, "edge node holds no edge data");
1934 };
1935 let curve = data.curve3d().and_then(|repr| {
1936 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1937 return None;
1938 };
1939 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1940 });
1941 (data.degenerate, curve)
1942 };
1943 let Some((start, end)) = edge_vertices(model, edge)? else {
1944 ogeom_bail!(Construction, "a lone edge has no vertices");
1945 };
1946 if degenerate {
1947 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1949 ogeom_bail!(Construction, "an apex has no re-solved vertex");
1950 };
1951 let mut data = EdgeData::new();
1952 data.degenerate = true;
1953 return Ok(Some(
1954 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1955 ));
1956 }
1957 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1958 else {
1959 return Ok(None);
1960 };
1961 let circle = c.circle();
1962 let mut moved_points = Vec::with_capacity(3);
1963 for k in 0..3 {
1964 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1965 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1966 let p = Curve::Circle(c).point_at(t, tol)?;
1967 let Some((n, w)) = constraint(model, fi, p)? else {
1968 return Ok(None);
1969 };
1970 moved_points.push(p + n * w);
1971 }
1972 let centre = Point::from_vector(
1976 moved_points
1977 .iter()
1978 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
1979 / 3.0,
1980 );
1981 let radius = centre.distance(moved_points[0]);
1982 let reframed = ogeom_math::Circle::new(
1983 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
1984 radius,
1985 tol,
1986 )?;
1987 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1988 Ok(Some(make_edge(model, moved, range, tol)?.shape))
1989}
1990
1991fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
1992 let mut a = [[0.0_f64; 3]; 3];
1994 let mut b = [0.0_f64; 3];
1995 for (n, w) in normals.iter().zip(amounts) {
1996 let row = [n.x, n.y, n.z];
1997 for i in 0..3 {
1998 for j in 0..3 {
1999 a[i][j] += row[i] * row[j];
2000 }
2001 b[i] += row[i] * w;
2002 }
2003 }
2004 if normals.len() == 2 {
2008 let along = normals[0].cross(normals[1]);
2009 let m = along.magnitude();
2010 if m <= tol.angular() {
2011 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
2012 }
2013 let d = along / m;
2014 let row = [d.x, d.y, d.z];
2015 for i in 0..3 {
2016 for j in 0..3 {
2017 a[i][j] += row[i] * row[j];
2018 }
2019 }
2020 }
2021 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
2022 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
2023 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
2024 if det.abs() <= tol.angular() * tol.angular() {
2025 ogeom_bail!(
2026 Construction,
2027 "a corner's faces are too nearly parallel to re-solve"
2028 );
2029 }
2030 let inv = |r: usize, c: usize| -> f64 {
2031 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
2032 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
2033 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
2034 };
2035 let mut x = [0.0_f64; 3];
2036 for (i, xi) in x.iter_mut().enumerate() {
2037 for (j, bj) in b.iter().enumerate() {
2038 *xi += inv(i, j) * bj;
2039 }
2040 }
2041 Ok(Vector::new(x[0], x[1], x[2]))
2042}
2043
2044fn growing_edges(
2049 model: &Model,
2050 body: &Shape,
2051 held: &[TShapeId],
2052 convex: bool,
2053 tol: Tolerances,
2054) -> OgeomResult<Vec<Shape>> {
2055 use ogeom_geom::{Curve3d as _, Surface as _};
2056 let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
2059 std::collections::HashMap::new();
2060 for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
2061 for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
2062 holders.entry(e.node()).or_default().push((e, face.clone()));
2063 }
2064 }
2065 let mut out = Vec::new();
2066 for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
2067 let mut faces: Vec<Shape> = Vec::new();
2068 for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
2069 if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
2070 faces.push(face.clone());
2071 }
2072 }
2073 let mut distinct: Vec<Shape> = Vec::new();
2074 for f in faces {
2075 if !distinct.iter().any(|d| d.node() == f.node()) {
2076 distinct.push(f);
2077 }
2078 }
2079 let [f1, f2] = distinct.as_slice() else {
2080 continue;
2081 };
2082 if held.contains(&f1.node()) || held.contains(&f2.node()) {
2083 continue;
2084 }
2085 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2086 continue;
2087 };
2088 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2089 continue;
2090 };
2091 let Some(geometry) = model.geometry().curve(*curve) else {
2092 continue;
2093 };
2094 let p = edge
2095 .transform(model.datums())?
2096 .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
2097 let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
2099 continue;
2100 };
2101 let Some(surface) = model.geometry().surface(face_data.surface) else {
2102 continue;
2103 };
2104 use ogeom_geom::Transformable as _;
2105 let placed = surface
2106 .clone()
2107 .transformed(&f1.transform(model.datums())?, tol)?;
2108 let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
2109 let mut n1 = placed.normal_at(u, v, tol)?.vector();
2110 if f1.orientation() == Orientation::Reversed {
2111 n1 = -n1;
2112 }
2113 let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
2116 let below = mesh
2117 .positions
2118 .iter()
2119 .map(|q| (*q - p).dot(n1))
2120 .max_by(|a, b| a.abs().total_cmp(&b.abs()))
2121 .unwrap_or(0.0);
2122 if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
2123 out.push(edge);
2124 }
2125 }
2126 Ok(out)
2127}