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 make_thick_solid(
147 model: &mut Model,
148 solid: &Shape,
149 removed: &[Shape],
150 thickness: f64,
151 tol: Tolerances,
152) -> OgeomResult<Built> {
153 make_thick_solid_with(model, solid, removed, thickness, Join::Intersection, tol)
154}
155
156pub fn make_thick_solid_with(
171 model: &mut Model,
172 solid: &Shape,
173 removed: &[Shape],
174 thickness: f64,
175 join: Join,
176 tol: Tolerances,
177) -> OgeomResult<Built> {
178 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
179 ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
180 }
181 let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
182 if let Some(prefix) = prefix {
183 let mut out = make_thick_solid_with(model, &canonical, &mapped, thickness, join, tol)?;
184 out.history = prefix.then(&out.history);
185 return Ok(out);
186 }
187 let outward_walls = thickness < 0.0;
190 let reach = thickness.abs();
191 let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
192 .iter()
193 .map(Shape::node)
194 .collect();
195 for face in removed {
196 if !own.contains(&face.node()) {
197 ogeom_bail!(Construction, "a removed face is not a face of the solid");
198 }
199 }
200
201 let mut tangent_opening = false;
202 for face in removed {
203 if has_tangent_neighbour(model, solid, face, tol)? {
204 tangent_opening = true;
205 break;
206 }
207 }
208 if !tangent_opening {
209 let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
210 let moved = rebuilt(
211 model,
212 solid,
213 &|face| {
214 if skip.contains(&face.node()) {
215 0.0
216 } else if outward_walls {
217 reach
218 } else {
219 -reach
220 }
221 },
222 &|_| None,
223 tol,
224 )?;
225 let moved = if join == Join::Arc {
229 let held: Vec<TShapeId> = removed
230 .iter()
231 .flat_map(|f| {
232 moved
233 .history
234 .modified(f)
235 .iter()
236 .map(Shape::node)
237 .collect::<Vec<_>>()
238 })
239 .collect();
240 let edges = growing_edges(model, &moved.shape, &held, outward_walls, tol)?;
241 if edges.is_empty() {
242 moved
243 } else {
244 ogeom_fillet::fillet_edges(model, &moved.shape, &edges, reach, tol)?
245 }
246 } else {
247 moved
248 };
249 let mut result = if outward_walls {
254 ogeom_bool::cut(model, &moved.shape, solid, tol)?
255 } else {
256 ogeom_bool::cut(model, solid, &moved.shape, tol)?
257 };
258 for face in removed {
259 result.history.delete(face);
260 }
261 return Ok(result);
262 }
263
264 if join == Join::Arc {
265 ogeom_bail!(
266 Construction,
267 "the arc join is built where every opening meets its neighbours \
268 across a corner; an opening with a tangent neighbour is not yet"
269 );
270 }
271 let displaced = if outward_walls { reach } else { -reach };
275 let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
276 let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
277 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
278 ogeom_bail!(Construction, "face node holds no face data");
279 };
280 let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
281 ogeom_bail!(
282 Construction,
283 "a tangent opening must be planar; a curved opening needs \
284 the general rebuild; see docs/PARITY.md, offset.shell-thicken"
285 );
286 };
287 let mut normal = p.plane().normal().vector();
288 if face.orientation() == Orientation::Reversed {
289 normal = -normal;
290 }
291 Ok(normal)
292 };
293 let mut result = if outward_walls {
294 let mut tool = solid.clone();
297 for face in removed {
298 let outward = opening_normal(model, face)?;
299 let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
300 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
301 }
302 ogeom_bool::cut(model, &moved.shape, &tool, tol)?
303 } else {
304 let mut tool = moved.shape.clone();
305 for face in removed {
306 let outward = opening_normal(model, face)?;
307 let [image] = moved.history.modified(face) else {
308 ogeom_bail!(Construction, "a removed face has no single cavity image");
309 };
310 let punch =
311 ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
312 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
313 }
314 ogeom_bool::cut(model, solid, &tool, tol)?
315 };
316 for face in removed {
317 result.history.delete(face);
318 }
319 Ok(result)
320}
321
322fn has_tangent_neighbour(
324 model: &Model,
325 solid: &Shape,
326 face: &Shape,
327 tol: Tolerances,
328) -> OgeomResult<bool> {
329 use ogeom_geom::Surface as _;
330
331 let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
332 .iter()
333 .map(Shape::node)
334 .collect();
335 let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
336 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
337 ogeom_bail!(Construction, "face node holds no face data");
338 };
339 let Some(surface) = model.geometry().surface(data.surface) else {
340 ogeom_bail!(Dangling, "face refers to a surface not in this model");
341 };
342 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
343 if projection.distance > tol.confusion() * 100.0 {
344 return Ok(None);
345 }
346 let (u, v) = projection.parameters;
347 let (du, dv) = surface.d1_at(u, v, tol)?;
348 let n = du.cross(dv);
349 let m = n.magnitude();
350 if m <= tol.confusion() {
351 return Ok(None);
352 }
353 Ok(Some(n / m))
354 };
355 for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
356 if other.node() == face.node() {
357 continue;
358 }
359 for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
360 if !own_edges.contains(&edge.node()) {
361 continue;
362 }
363 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
364 continue;
365 };
366 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
367 continue;
368 };
369 let Some(geometry) = model.geometry().curve(*curve) else {
370 continue;
371 };
372 let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
373 let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
374 else {
375 continue;
376 };
377 if a.cross(b).magnitude() <= 1e-6 {
378 return Ok(true);
379 }
380 }
381 }
382 Ok(false)
383}
384
385struct Prepared {
387 shape: Shape,
388 surface: SurfaceGeometry,
390 amount: f64,
392 sign: f64,
395 rings: Option<[Shape; 2]>,
397}
398
399pub(crate) fn rebuilt(
420 model: &mut Model,
421 solid: &Shape,
422 amount_of: &dyn Fn(&Shape) -> f64,
423 instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
424 tol: Tolerances,
425) -> OgeomResult<Built> {
426 use ogeom_geom::Surface as _;
427 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
428
429 let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
431 for face in &faces {
432 let amount = amount_of(face);
433 let Some(node) = model.node(face) else {
434 ogeom_bail!(Dangling, "face is not in this model");
435 };
436 let NodeData::Face(data) = node.data() else {
437 ogeom_bail!(Construction, "face node holds no face data");
438 };
439 let Some(surface) = model.geometry().surface(data.surface) else {
440 ogeom_bail!(Dangling, "face refers to a surface not in this model");
441 };
442 let sign = if face.orientation() == Orientation::Reversed {
443 -1.0
444 } else {
445 1.0
446 };
447 let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
448 let mut counts: HashMap<TShapeId, usize> = HashMap::new();
449 for e in &edges {
450 *counts.entry(e.node()).or_insert(0) += 1;
451 }
452 let has_seam = counts.values().any(|c| *c >= 2);
453 let closed_rings: Vec<Shape> = edges
454 .iter()
455 .filter(|e| {
456 edge_vertices(model, e)
457 .ok()
458 .flatten()
459 .is_some_and(|(a, b)| a.node() == b.node())
460 })
461 .cloned()
462 .collect();
463
464 let grow = amount.abs() * 4.0 + 1.0;
465 let replacement = instead_of(face);
466 let moved: SurfaceGeometry = if let Some(given) = replacement {
467 given
468 } else if amount == 0.0 {
469 surface.clone()
473 } else {
474 match surface {
475 SurfaceGeometry::Plane(p) => {
476 let plane = p.plane();
477 let ((u0, u1), (v0, v1)) = surface.domain();
478 let shifted = Plane::new(Frame::new(
479 plane.origin() + plane.normal().vector() * (sign * amount),
480 plane.normal(),
481 plane.frame().x(),
482 tol,
483 )?);
484 PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
485 .into()
486 }
487 SurfaceGeometry::Cylinder(c) => {
488 let cylinder = c.cylinder();
489 let grown = sign.mul_add(amount, cylinder.radius());
490 if grown <= tol.confusion() {
491 ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
492 }
493 let (_, (v0, v1)) = surface.domain();
494 CylinderSurface::new(
495 Cylinder::new(cylinder.frame(), grown, tol)?,
496 (v0 - grow, v1 + grow),
497 )?
498 .into()
499 }
500 SurfaceGeometry::Sphere(sp) => {
501 let sphere = sp.sphere();
502 let grown = sign.mul_add(amount, sphere.radius());
503 if grown <= tol.confusion() {
504 ogeom_bail!(Construction, "the offset consumes the sphere's radius");
505 }
506 ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
507 sphere.centre(),
508 grown,
509 tol,
510 )?)
511 .into()
512 }
513 SurfaceGeometry::Torus(t) => {
514 let torus = t.torus();
515 let grown = sign.mul_add(amount, torus.minor_radius());
516 if grown <= tol.confusion() {
517 ogeom_bail!(Construction, "the offset consumes the torus's tube");
518 }
519 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
520 torus.frame(),
521 torus.major_radius(),
522 grown,
523 tol,
524 )?)
525 .into()
526 }
527 SurfaceGeometry::Cone(co) => {
528 let cone = co.cone();
529 let grown = (sign * amount / cone.half_angle().cos())
532 .mul_add(1.0, cone.reference_radius());
533 if grown <= tol.confusion() {
534 ogeom_bail!(Construction, "the offset consumes the cone's throat");
535 }
536 let (_, (v0, v1)) = surface.domain();
537 ogeom_geom::ConeSurface::new(
538 ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
539 (v0 - grow, v1 + grow),
540 )?
541 .into()
542 }
543 _ => ogeom_bail!(
544 Construction,
545 "offsetting a face on this surface needs a construction \
546 the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
547 ),
548 }
549 };
550 let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
554 prepared.push(Prepared {
555 shape: face.clone(),
556 surface: moved,
557 amount,
558 sign,
559 rings: if has_seam && closed_rings.len() == 2 && !fitted_support {
560 Some([closed_rings[0].clone(), closed_rings[1].clone()])
561 } else {
562 None
563 },
564 });
565 }
566
567 let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
569 for (fi, face) in faces.iter().enumerate() {
570 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
571 let entry = edge_faces.entry(e.node()).or_default();
572 if !entry.contains(&fi) {
573 entry.push(fi);
574 }
575 }
576 }
577
578 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
582 let face = &faces[fi];
583 let Some(node) = model.node(face) else {
584 ogeom_bail!(Dangling, "face is not in this model");
585 };
586 let NodeData::Face(data) = node.data() else {
587 ogeom_bail!(Construction, "face node holds no face data");
588 };
589 let Some(surface) = model.geometry().surface(data.surface) else {
590 ogeom_bail!(Dangling, "face refers to a surface not in this model");
591 };
592 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
593 if projection.distance > tol.confusion() * 100.0 {
594 return Ok(None);
595 }
596 let (u, v) = projection.parameters;
597 let (du, dv) = surface.d1_at(u, v, tol)?;
598 let n = du.cross(dv);
599 let m = n.magnitude();
600 if m <= tol.confusion() {
601 return Ok(None);
602 }
603 let outward = n / m * prepared[fi].sign;
604 Ok(Some((outward, prepared[fi].amount)))
605 };
606
607 let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
611 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
612 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
613 continue;
614 };
615 let at = vertex.transform(model.datums())?.apply(data.point);
616 let mut seats: Vec<usize> = Vec::new();
617 for (fi, face) in faces.iter().enumerate() {
618 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
619 if v.node() == vertex.node() && !seats.contains(&fi) {
620 seats.push(fi);
621 }
622 }
623 }
624 if seats.is_empty() {
625 continue;
626 }
627 let mut normals: Vec<Vector> = Vec::new();
630 let mut amounts: Vec<f64> = Vec::new();
631 let mut kept: Vec<usize> = Vec::new();
632 for fi in &seats {
633 let Some((n, w)) = constraint(model, *fi, at)? else {
634 continue;
635 };
636 if let Some(k) = normals
637 .iter()
638 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
639 {
640 if (amounts[k] - w).abs() > tol.confusion() {
641 ogeom_bail!(
642 Construction,
643 "two tangent faces move a shared vertex by different \
644 amounts; the offset tears it"
645 );
646 }
647 continue;
648 }
649 normals.push(n);
650 amounts.push(w);
651 kept.push(*fi);
652 }
653 if normals.is_empty() {
654 let mut apex: Option<Point> = None;
658 for fi in &seats {
659 let Some(node) = model.node(&faces[*fi]) else {
660 continue;
661 };
662 let NodeData::Face(data) = node.data() else {
663 continue;
664 };
665 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
666 else {
667 continue;
668 };
669 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
670 continue;
671 }
672 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
673 apex = Some(moved_cone.cone().apex());
674 break;
675 }
676 }
677 let Some(moved) = apex else {
678 ogeom_bail!(
679 Construction,
680 "a vertex with no seat the rebuild can read cannot be \
681 re-solved"
682 );
683 };
684 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
685 continue;
686 }
687 if normals.len() == 1 {
688 let moved = at + normals[0] * amounts[0];
693 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
694 continue;
695 }
696 if kept.len() == 2
702 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
703 {
704 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
705 continue;
706 }
707 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
708 for _ in 0..8 {
712 let mut ns: Vec<Vector> = Vec::new();
713 let mut rs: Vec<f64> = Vec::new();
714 for fi in &kept {
715 let projection =
716 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
717 let (u, v) = projection.parameters;
718 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
719 let n = du.cross(dv);
720 let m = n.magnitude();
721 if m <= tol.confusion() {
722 continue;
723 }
724 let n = n / m;
725 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
726 ns.push(n);
727 rs.push((moved - foot).dot(n));
728 }
729 if ns.len() < 2 {
730 break;
731 }
732 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
733 if worst <= tol.confusion() * 0.1 {
734 break;
735 }
736 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
737 moved += solve_corner(&ns, &step, tol)?;
738 }
739 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
740 }
741
742 let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
745 for face in &faces {
746 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
747 *edge_uses.entry(e.node()).or_insert(0) += 1;
748 }
749 }
750
751 let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
753 let mut history = History::new();
754 for edge in explore_unique(model, solid, ShapeType::Edge)? {
755 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
756 if sides.len() != 2 {
757 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
758 if let [fi] = sides.as_slice()
763 && let Some(built) =
764 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
765 {
766 history.modify(&edge, built.clone());
767 new_edges.insert(edge.node(), built);
768 }
769 continue;
770 }
771 let Some(built) =
774 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
775 else {
776 ogeom_bail!(
777 Construction,
778 "an edge with one face is neither a ring nor an apex; \
779 the offset cannot re-derive it"
780 );
781 };
782 history.modify(&edge, built.clone());
783 new_edges.insert(edge.node(), built);
784 continue;
785 }
786 let (curve, range) = {
787 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
788 ogeom_bail!(Construction, "edge node holds no edge data");
789 };
790 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
791 ogeom_bail!(Construction, "an edge has no curve to offset");
792 };
793 let Some(geometry) = model.geometry().curve(*curve) else {
794 ogeom_bail!(Dangling, "curve is not in this model");
795 };
796 (geometry.clone(), *range)
797 };
798 let forward = if edge.orientation() == Orientation::Reversed {
799 edge.reversed()
800 } else {
801 edge.clone()
802 };
803 let built = match &curve {
804 Curve::Line(_) => {
805 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
812 ogeom_bail!(Construction, "a straight edge has no vertices");
813 };
814 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
815 new_vertices.get(&sv.node()).cloned(),
816 new_vertices.get(&ev.node()).cloned(),
817 ) else {
818 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
819 };
820 if p_to.distance(p_from) <= tol.parametric() {
821 ogeom_bail!(Construction, "the offset collapses an edge");
822 }
823 let segment = LineCurve::segment(p_from, p_to, tol)?;
824 let (t0, t1) = segment.domain();
825 let moved: Curve = segment.into();
826 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
827 }
828 Curve::Circle(c)
829 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
830 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
831 {
832 let circle = c.circle();
837 let found = ogeom_intersect::intersect_surfaces(
838 &prepared[sides[0]].surface,
839 &prepared[sides[1]].surface,
840 ogeom_intersect::IntersectOptions::default(),
841 tol,
842 )?;
843 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
844 ogeom_bail!(
845 Construction,
846 "the moved faces no longer meet along the edge they \
847 shared; the offset collapses it"
848 );
849 };
850 let mut best: Option<(ogeom_math::Circle, f64)> = None;
851 for section in &candidates {
852 let Curve::Circle(cc) = §ion.curve else {
853 continue;
854 };
855 let candidate = cc.circle();
856 let score = candidate.centre().distance(circle.centre())
857 + (candidate.radius() - circle.radius()).abs();
858 if best.as_ref().is_none_or(|(_, held)| score < *held) {
859 best = Some((candidate, score));
860 }
861 }
862 let Some((candidate, _)) = best else {
863 ogeom_bail!(
864 Construction,
865 "the moved faces meet along nothing circular where a \
866 circle was; the offset needs the general rebuild"
867 );
868 };
869 let reframed = ogeom_math::Circle::new(
870 Frame::new(
871 candidate.centre(),
872 circle.frame().z(),
873 circle.frame().x(),
874 tol,
875 )?,
876 candidate.radius(),
877 tol,
878 )?;
879 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
880 let closed = {
881 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
882 ogeom_bail!(Construction, "a ring has no vertex");
883 };
884 sv.node() == ev.node()
885 };
886 if closed {
887 make_edge(model, moved, range, tol)?.shape
888 } else {
889 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
890 ogeom_bail!(Construction, "an arc has no vertices");
891 };
892 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
893 new_vertices.get(&sv.node()).cloned(),
894 new_vertices.get(&ev.node()).cloned(),
895 ) else {
896 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
897 };
898 let angle_of = |p: Point| {
899 let l = reframed.frame().to_local(p);
900 l.y.atan2(l.x)
901 };
902 let tau = core::f64::consts::TAU;
903 let mut t0 = angle_of(p_from);
904 let mut t1 = angle_of(p_to);
905 while t0 < range.0 - core::f64::consts::PI {
907 t0 += tau;
908 }
909 while t0 > range.0 + core::f64::consts::PI {
910 t0 -= tau;
911 }
912 while t1 <= t0 + tol.parametric() {
913 t1 += tau;
914 }
915 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
916 t1 -= tau;
917 }
918 if t1 <= t0 + tol.parametric() {
919 ogeom_bail!(Construction, "the offset collapses an arc");
920 }
921 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
922 }
923 }
924 _ => {
925 let unmoved = {
930 let mut worst = 0.0_f64;
931 'probe: for i in 0..9 {
932 #[allow(clippy::cast_precision_loss)]
933 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
934 let p = curve.point_at(t, tol)?;
935 for side in [sides[0], sides[1]] {
936 let Ok(near) =
937 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
938 else {
939 worst = f64::INFINITY;
940 break 'probe;
941 };
942 worst = worst.max(near.distance);
943 }
944 }
945 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
949 };
950 if let Some(worst) = unmoved {
951 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
952 ogeom_bail!(Construction, "an edge has no vertices");
953 };
954 let closed = sv.node() == ev.node();
955 let built = if closed {
956 match new_vertices.get(&sv.node()).cloned() {
959 Some((v_at, p_at)) => {
960 let gap = curve.point_at(range.0, tol)?.distance(p_at);
961 if gap > tol.confusion() {
962 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
963 }
964 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
965 .shape
966 }
967 None => make_edge(model, curve.clone(), range, tol)?.shape,
968 }
969 } else {
970 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
971 new_vertices.get(&sv.node()).cloned(),
972 new_vertices.get(&ev.node()).cloned(),
973 ) else {
974 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
975 };
976 let gap = curve
980 .point_at(range.0, tol)?
981 .distance(p_from)
982 .min(curve.point_at(range.0, tol)?.distance(p_to))
983 .max(
984 curve
985 .point_at(range.1, tol)?
986 .distance(p_to)
987 .min(curve.point_at(range.1, tol)?.distance(p_from)),
988 );
989 if gap > tol.confusion() {
990 for v in [&v_from, &v_to] {
991 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
992 }
993 }
994 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
995 };
996 if worst > tol.confusion()
997 && let Some(node) = model.node_mut(&built)
998 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
999 {
1000 data.tolerance = data.tolerance.widen_to(worst);
1001 }
1002 history.modify(&edge, built.clone());
1003 new_edges.insert(edge.node(), built);
1004 continue;
1005 }
1006 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1013 let found = ogeom_intersect::intersect_surfaces(
1014 &prepared[sides[0]].surface,
1015 &prepared[sides[1]].surface,
1016 ogeom_intersect::IntersectOptions::default(),
1017 tol,
1018 )?;
1019 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
1020 ogeom_bail!(
1021 Construction,
1022 "the moved faces no longer meet along the edge they \
1023 shared; the offset collapses it"
1024 );
1025 };
1026 let mut best: Option<(Curve, f64, f64)> = None;
1027 for section in candidates {
1028 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
1029 else {
1030 continue;
1031 };
1032 if best
1033 .as_ref()
1034 .is_none_or(|(_, _, held)| projected.distance < *held)
1035 {
1036 best = Some((section.curve, section.tolerance, projected.distance));
1037 }
1038 }
1039 let Some((moved, slop, _)) = best else {
1040 ogeom_bail!(
1041 Construction,
1042 "the moved faces meet along nothing where the edge \
1043 was; the offset collapses it"
1044 );
1045 };
1046 let closed = {
1047 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1048 ogeom_bail!(Construction, "an edge has no vertices");
1049 };
1050 sv.node() == ev.node()
1051 };
1052 let built = if closed {
1053 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1059 ogeom_bail!(Construction, "a ring has no vertex");
1060 };
1061 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1062 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1063 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1064 let (lo, hi) = moved.domain();
1065 let seamed: Curve = if t.parameter > lo + tol.parametric()
1066 && t.parameter < hi - tol.parametric()
1067 {
1068 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1069 } else {
1070 moved.clone()
1071 };
1072 let seamed = {
1076 use ogeom_geom::Reversible as _;
1077 let (a, _) = seamed.domain();
1078 let old = curve.d1_at(range.0, tol)?;
1079 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1080 seamed.reversed()
1081 } else {
1082 seamed
1083 }
1084 };
1085 let miss = t.distance.max(slop);
1086 if miss > tol.confusion() {
1087 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1088 }
1089 let window = seamed.domain();
1090 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1091 }
1092 _ => {
1093 let window = moved.domain();
1094 make_edge(model, moved, window, tol)?.shape
1095 }
1096 }
1097 } else {
1098 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1099 ogeom_bail!(Construction, "an edge has no vertices");
1100 };
1101 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1102 new_vertices.get(&sv.node()).cloned(),
1103 new_vertices.get(&ev.node()).cloned(),
1104 ) else {
1105 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1106 };
1107 if slop > tol.confusion() {
1110 for v in [&v_from, &v_to] {
1111 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1112 }
1113 }
1114 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1115 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1116 if (tb - ta).abs() <= tol.parametric() {
1117 ogeom_bail!(Construction, "the offset collapses an edge");
1118 }
1119 let (moved, ta, tb) = if ta <= tb {
1123 (moved, ta, tb)
1124 } else {
1125 use ogeom_geom::Reversible as _;
1126 let (lo, hi) = moved.domain();
1127 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1128 };
1129 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1130 };
1131 if slop > tol.confusion()
1132 && let Some(node) = model.node_mut(&built)
1133 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1134 {
1135 data.tolerance = data.tolerance.widen_to(slop);
1136 }
1137 built
1138 }
1139 };
1140 history.modify(&edge, built.clone());
1141 new_edges.insert(edge.node(), built);
1142 }
1143
1144 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1147 for prep in &prepared {
1148 let built = if let Some(rings) = &prep.rings {
1149 let (Some(lo), Some(hi)) = (
1150 new_edges.get(&rings[0].node()),
1151 new_edges.get(&rings[1].node()),
1152 ) else {
1153 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1154 };
1155 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1156 if prep.shape.orientation() == Orientation::Reversed {
1157 band.reversed()
1158 } else {
1159 band
1160 }
1161 } else {
1162 let mut wires: Vec<Vec<Shape>> = Vec::new();
1163 let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1164 for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1165 let mut edges: Vec<Shape> = Vec::new();
1166 for used in model.ordered_children_of(&wire)? {
1169 *face_uses.entry(used.node()).or_insert(0) += 1;
1170 let Some(fresh) = new_edges.get(&used.node()) else {
1171 ogeom_bail!(Construction, "a face edge was not rebuilt");
1172 };
1173 edges.push(if used.orientation() == Orientation::Reversed {
1174 fresh.reversed()
1175 } else {
1176 fresh.clone()
1177 });
1178 }
1179 wires.push(edges);
1180 }
1181 let face = if face_uses.values().any(|c| *c >= 2) {
1182 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1187 } else {
1188 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1189 };
1190 if prep.shape.orientation() == Orientation::Reversed {
1191 face.reversed()
1192 } else {
1193 face
1194 }
1195 };
1196 history.modify(&prep.shape, built.clone());
1197 rebuilt_faces.push(built);
1198 }
1199
1200 let sewn = sew(model, &rebuilt_faces, tol)?;
1201 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1202 ogeom_bail!(Construction, "the offset solid did not close");
1203 }
1204 let outer = {
1208 let old_reversed = model
1209 .children_of(solid)?
1210 .first()
1211 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1212 if old_reversed {
1213 sewn.shells[0].reversed()
1214 } else {
1215 sewn.shells[0].clone()
1216 }
1217 };
1218 let built = make_solid(model, std::slice::from_ref(&outer))?;
1219
1220 let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
1231 let fine = (size * 1e-5).clamp(
1232 tol.confusion() * 1e2,
1233 ogeom_mesh::Deflection::default().chord,
1234 );
1235 let mut mass = None;
1236 let mut first_error = None;
1237 for chord in [ogeom_mesh::Deflection::default().chord, fine] {
1238 let deflection = ogeom_mesh::Deflection {
1239 chord,
1240 ..ogeom_mesh::Deflection::default()
1241 };
1242 match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1243 Ok(props) => {
1244 mass = Some(props.mass);
1245 break;
1246 }
1247 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
1248 return Err(e);
1249 }
1250 Err(e) => {
1251 first_error.get_or_insert(e);
1252 }
1253 }
1254 }
1255 let Some(mass) = mass else {
1256 ogeom_bail!(
1257 Construction,
1258 "the offset solid's mesh does not close at any tried resolution{}",
1259 first_error.map_or_else(String::new, |e| format!(": {e}"))
1260 );
1261 };
1262 if !mass.is_finite() || mass <= tol.confusion() {
1263 ogeom_bail!(Construction, "the offset collapses the solid");
1264 }
1265
1266 history.modify(solid, built.shape.clone());
1267 Ok(Built::new(built.shape, history))
1268}
1269
1270fn rebuilt_seam_edge(
1282 model: &mut Model,
1283 edge: &Shape,
1284 prep: &Prepared,
1285 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1286 tol: Tolerances,
1287) -> OgeomResult<Option<Shape>> {
1288 use ogeom_geom::Curve2d as _;
1289
1290 let old_surface = {
1291 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1292 else {
1293 ogeom_bail!(Construction, "face node holds no face data");
1294 };
1295 data.surface
1296 };
1297 let found = {
1298 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1299 ogeom_bail!(Construction, "edge node holds no edge data");
1300 };
1301 let mut found = None;
1302 for repr in &data.representations {
1303 if let EdgeRepr::Seam {
1304 forward,
1305 surface,
1306 range,
1307 ..
1308 } = repr
1309 && *surface == old_surface
1310 {
1311 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1312 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1313 };
1314 found = Some(pcurve.point_at(range.0, tol)?.x);
1318 break;
1319 }
1320 }
1321 found
1322 };
1323 let Some(column) = found else {
1324 return Ok(None);
1325 };
1326 let Some((sv, ev)) = edge_vertices(model, edge)? else {
1327 ogeom_bail!(Construction, "a seam has no vertices");
1328 };
1329 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1330 new_vertices.get(&sv.node()).cloned(),
1331 new_vertices.get(&ev.node()).cloned(),
1332 ) else {
1333 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1334 };
1335 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1336 ogeom_bail!(
1337 Construction,
1338 "the moved surface's iso-curve has no closed form; no seam can \
1339 be rebuilt"
1340 );
1341 };
1342 let along = |p: Point| -> OgeomResult<f64> {
1346 match &curve {
1347 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1348 Curve::Circle(c) => {
1349 let local = c.circle().frame().to_local(p);
1350 let mut angle = local.y.atan2(local.x);
1351 if angle < 0.0 {
1352 angle += core::f64::consts::TAU;
1353 }
1354 Ok(angle)
1355 }
1356 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1360 }
1361 };
1362 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1363 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1373 (tol.confusion() * 1e3).max(1e-4) * 4.0
1374 } else {
1375 tol.confusion() * 100.0
1376 };
1377 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1378 let off = curve.point_at(t, tol)?.distance(p);
1379 if off > slack {
1380 return Ok(None);
1381 }
1382 if off > tol.confusion() {
1385 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1386 }
1387 }
1388 Ok(Some(if t_start <= t_end {
1389 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1390 } else {
1391 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1395 .shape
1396 .reversed()
1397 }))
1398}
1399
1400fn assembled_with_seam(
1408 model: &mut Model,
1409 prep: &Prepared,
1410 wires: &[Vec<Shape>],
1411 new_edges: &HashMap<TShapeId, Shape>,
1412 tol: Tolerances,
1413) -> OgeomResult<Shape> {
1414 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1415 for edges in wires {
1416 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1417 }
1418 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1419 let new_surface = {
1420 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1421 ogeom_bail!(Construction, "the face just built holds no face data");
1422 };
1423 data.surface
1424 };
1425 let old_surface = {
1426 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1427 else {
1428 ogeom_bail!(Construction, "face node holds no face data");
1429 };
1430 data.surface
1431 };
1432
1433 let mut done: Vec<TShapeId> = Vec::new();
1434 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1435 if done.contains(&used.node()) {
1436 continue;
1437 }
1438 done.push(used.node());
1439 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1440 ogeom_bail!(Construction, "a face edge was not rebuilt");
1441 };
1442 let (fresh_curve, fresh_range) = {
1443 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1444 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1445 };
1446 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1447 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1448 };
1449 let Some(geometry) = model.geometry().curve(*curve) else {
1450 ogeom_bail!(Dangling, "curve is not in this model");
1451 };
1452 (geometry.clone(), *range)
1453 };
1454 let columns = {
1455 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1456 ogeom_bail!(Construction, "edge node holds no edge data");
1457 };
1458 let mut columns = None;
1459 for repr in &data.representations {
1460 if let EdgeRepr::Seam {
1461 forward,
1462 reversed,
1463 surface,
1464 range,
1465 ..
1466 } = repr
1467 && *surface == old_surface
1468 {
1469 use ogeom_geom::Curve2d as _;
1470 let (Some(f), Some(r)) = (
1471 model.geometry().pcurve(*forward),
1472 model.geometry().pcurve(*reversed),
1473 ) else {
1474 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1475 };
1476 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1477 break;
1478 }
1479 }
1480 columns
1481 };
1482 if let Some((forward_col, reversed_col)) = columns {
1483 let rows = match &prep.surface {
1486 SurfaceGeometry::Cone(c) => {
1487 let cos = c.cone().half_angle().cos();
1488 (fresh_range.0 * cos, fresh_range.1 * cos)
1489 }
1490 _ => fresh_range,
1491 };
1492 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1493 Ok(ogeom_geom::Line2d::over(
1494 ogeom_math::Axis2::new(
1495 ogeom_math::Point2::new(u, 0.0),
1496 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1497 ),
1498 rows.0 - 1.0,
1499 rows.1 + 1.0,
1500 )?
1501 .into())
1502 };
1503 ogeom_algo::attach_seam(
1504 model,
1505 &fresh,
1506 column(forward_col)?,
1507 column(reversed_col)?,
1508 new_surface,
1509 ogeom_topo::Location::identity(),
1510 rows,
1511 )?;
1512 } else {
1513 let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
1517 Some(exact) => exact,
1518 None => {
1519 let (fitted, _, _, worst_off, _) =
1520 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1521 &fresh_curve,
1522 fresh_range,
1523 &prep.surface,
1524 tol,
1525 )?;
1526 if worst_off > tol.confusion() {
1527 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1531 model.widen(&fresh, widened)?;
1532 if let Some((a, b)) = edge_vertices(model, &fresh)? {
1533 model.widen(&a, widened)?;
1534 model.widen(&b, widened)?;
1535 }
1536 }
1537 fitted
1538 }
1539 };
1540 ogeom_algo::attach_pcurve(
1541 model,
1542 &fresh,
1543 pcurve,
1544 new_surface,
1545 ogeom_topo::Location::identity(),
1546 fresh_range,
1547 )?;
1548 }
1549 }
1550 Ok(face)
1551}
1552
1553fn seam_end(
1558 model: &Model,
1559 faces: &[Shape],
1560 prepared: &[Prepared],
1561 vertex: &Shape,
1562 kept: &[usize],
1563 at: Point,
1564 tol: Tolerances,
1565) -> OgeomResult<Option<Point>> {
1566 use ogeom_geom::Curve2d as _;
1567 for (slot, &fi) in kept.iter().enumerate() {
1568 let other = kept[1 - slot];
1569 let face = &faces[fi];
1570 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1571 continue;
1572 };
1573 let old_surface = data.surface;
1574 let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1575 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1576 *uses.entry(e.node()).or_insert(0) += 1;
1577 }
1578 for e in explore_unique(model, face, ShapeType::Edge)? {
1579 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1580 continue;
1581 }
1582 let Some((a, b)) = edge_vertices(model, &e)? else {
1583 continue;
1584 };
1585 if a.node() != vertex.node() && b.node() != vertex.node() {
1586 continue;
1587 }
1588 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1589 continue;
1590 };
1591 let mut column = None;
1592 for repr in &edata.representations {
1593 if let EdgeRepr::Seam {
1594 forward,
1595 surface,
1596 range,
1597 ..
1598 } = repr
1599 && *surface == old_surface
1600 && let Some(pcurve) = model.geometry().pcurve(*forward)
1601 {
1602 column = Some(pcurve.point_at(range.0, tol)?.x);
1603 break;
1604 }
1605 }
1606 let Some(column) = column else {
1607 continue;
1608 };
1609 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1610 else {
1611 continue;
1612 };
1613 let found = ogeom_intersect::intersect_curve_surface(
1614 &iso,
1615 &prepared[other].surface,
1616 ogeom_intersect::CurveSurfaceOptions::default(),
1617 tol,
1618 )?;
1619 let nearest = found
1620 .crossings
1621 .iter()
1622 .map(|hit| hit.point)
1623 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1624 if let Some(p) = nearest {
1625 return Ok(Some(p));
1626 }
1627 }
1628 }
1629 Ok(None)
1630}
1631
1632fn rebuilt_lone_edge(
1639 model: &mut Model,
1640 edge: &Shape,
1641 sides: &[usize],
1642 constraint: &Displacement<'_>,
1643 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1644 tol: Tolerances,
1645) -> OgeomResult<Option<Shape>> {
1646 use ogeom_geom::Curve3d as _;
1647
1648 let (degenerate, curve) = {
1649 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1650 ogeom_bail!(Construction, "edge node holds no edge data");
1651 };
1652 let curve = data.curve3d().and_then(|repr| {
1653 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1654 return None;
1655 };
1656 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1657 });
1658 (data.degenerate, curve)
1659 };
1660 let Some((start, end)) = edge_vertices(model, edge)? else {
1661 ogeom_bail!(Construction, "a lone edge has no vertices");
1662 };
1663 if degenerate {
1664 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1666 ogeom_bail!(Construction, "an apex has no re-solved vertex");
1667 };
1668 let mut data = EdgeData::new();
1669 data.degenerate = true;
1670 return Ok(Some(
1671 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1672 ));
1673 }
1674 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1675 else {
1676 return Ok(None);
1677 };
1678 let circle = c.circle();
1679 let mut moved_points = Vec::with_capacity(3);
1680 for k in 0..3 {
1681 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1682 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1683 let p = Curve::Circle(c).point_at(t, tol)?;
1684 let Some((n, w)) = constraint(model, fi, p)? else {
1685 return Ok(None);
1686 };
1687 moved_points.push(p + n * w);
1688 }
1689 let centre = Point::from_vector(
1693 moved_points
1694 .iter()
1695 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
1696 / 3.0,
1697 );
1698 let radius = centre.distance(moved_points[0]);
1699 let reframed = ogeom_math::Circle::new(
1700 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
1701 radius,
1702 tol,
1703 )?;
1704 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1705 Ok(Some(make_edge(model, moved, range, tol)?.shape))
1706}
1707
1708fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
1709 let mut a = [[0.0_f64; 3]; 3];
1711 let mut b = [0.0_f64; 3];
1712 for (n, w) in normals.iter().zip(amounts) {
1713 let row = [n.x, n.y, n.z];
1714 for i in 0..3 {
1715 for j in 0..3 {
1716 a[i][j] += row[i] * row[j];
1717 }
1718 b[i] += row[i] * w;
1719 }
1720 }
1721 if normals.len() == 2 {
1725 let along = normals[0].cross(normals[1]);
1726 let m = along.magnitude();
1727 if m <= tol.angular() {
1728 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
1729 }
1730 let d = along / m;
1731 let row = [d.x, d.y, d.z];
1732 for i in 0..3 {
1733 for j in 0..3 {
1734 a[i][j] += row[i] * row[j];
1735 }
1736 }
1737 }
1738 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
1739 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
1740 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
1741 if det.abs() <= tol.angular() * tol.angular() {
1742 ogeom_bail!(
1743 Construction,
1744 "a corner's faces are too nearly parallel to re-solve"
1745 );
1746 }
1747 let inv = |r: usize, c: usize| -> f64 {
1748 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
1749 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
1750 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
1751 };
1752 let mut x = [0.0_f64; 3];
1753 for (i, xi) in x.iter_mut().enumerate() {
1754 for (j, bj) in b.iter().enumerate() {
1755 *xi += inv(i, j) * bj;
1756 }
1757 }
1758 Ok(Vector::new(x[0], x[1], x[2]))
1759}
1760
1761fn growing_edges(
1766 model: &Model,
1767 body: &Shape,
1768 held: &[TShapeId],
1769 convex: bool,
1770 tol: Tolerances,
1771) -> OgeomResult<Vec<Shape>> {
1772 use ogeom_geom::{Curve3d as _, Surface as _};
1773 let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
1776 std::collections::HashMap::new();
1777 for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
1778 for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
1779 holders.entry(e.node()).or_default().push((e, face.clone()));
1780 }
1781 }
1782 let mut out = Vec::new();
1783 for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
1784 let mut faces: Vec<Shape> = Vec::new();
1785 for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
1786 if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
1787 faces.push(face.clone());
1788 }
1789 }
1790 let mut distinct: Vec<Shape> = Vec::new();
1791 for f in faces {
1792 if !distinct.iter().any(|d| d.node() == f.node()) {
1793 distinct.push(f);
1794 }
1795 }
1796 let [f1, f2] = distinct.as_slice() else {
1797 continue;
1798 };
1799 if held.contains(&f1.node()) || held.contains(&f2.node()) {
1800 continue;
1801 }
1802 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1803 continue;
1804 };
1805 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1806 continue;
1807 };
1808 let Some(geometry) = model.geometry().curve(*curve) else {
1809 continue;
1810 };
1811 let p = edge
1812 .transform(model.datums())?
1813 .apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
1814 let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
1816 continue;
1817 };
1818 let Some(surface) = model.geometry().surface(face_data.surface) else {
1819 continue;
1820 };
1821 use ogeom_geom::Transformable as _;
1822 let placed = surface
1823 .clone()
1824 .transformed(&f1.transform(model.datums())?, tol)?;
1825 let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
1826 let mut n1 = placed.normal_at(u, v, tol)?.vector();
1827 if f1.orientation() == Orientation::Reversed {
1828 n1 = -n1;
1829 }
1830 let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
1833 let below = mesh
1834 .positions
1835 .iter()
1836 .map(|q| (*q - p).dot(n1))
1837 .max_by(|a, b| a.abs().total_cmp(&b.abs()))
1838 .unwrap_or(0.0);
1839 if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
1840 out.push(edge);
1841 }
1842 }
1843 Ok(out)
1844}