1use ogeom_algo::{
30 Built, History, edge_vertices, make_edge, make_edge_between, make_face_with_pcurves,
31 make_revolution_band, make_solid, make_vertex, sew,
32};
33use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
34use ogeom_geom::Curve3d as _;
35use ogeom_geom::{Curve, CylinderSurface, LineCurve, PlaneSurface, SurfaceGeometry};
36use ogeom_math::{Cylinder, Frame, Plane, Point, Vector};
37use ogeom_topo::{
38 EdgeData, EdgeRepr, Filter, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
39 explore_unique,
40};
41
42use std::collections::HashMap;
43
44type Displacement<'a> = dyn Fn(&Model, usize, Point) -> OgeomResult<Option<(Vector, f64)>> + 'a;
46
47pub(crate) fn canonical_input(
56 model: &mut Model,
57 solid: &Shape,
58 handles: &[Shape],
59 tol: Tolerances,
60) -> OgeomResult<(Shape, Vec<Shape>, Option<ogeom_algo::History>)> {
61 let probe = Point::new(0.123_456_789, 9.87, -3.21);
62 let mut seen: HashMap<TShapeId, Point> = HashMap::new();
63 let mut instanced = false;
64 'outer: for kind in [ShapeType::Vertex, ShapeType::Edge] {
65 for occurrence in explore(model, solid, Filter::OfType(kind))? {
66 let at = occurrence.transform(model.datums())?.apply(probe);
67 match seen.entry(occurrence.node()) {
68 std::collections::hash_map::Entry::Occupied(held) => {
69 if held.get().distance(at) > tol.confusion() {
70 instanced = true;
71 break 'outer;
72 }
73 }
74 std::collections::hash_map::Entry::Vacant(slot) => {
75 slot.insert(at);
76 }
77 }
78 }
79 }
80 if !instanced {
81 return Ok((solid.clone(), handles.to_vec(), None));
82 }
83 let baked = ogeom_algo::baked_shape(model, solid, tol)?;
84 let mapped = handles
85 .iter()
86 .map(|h| match baked.history.trace(h) {
87 [one] => Ok(one.clone()),
88 traced => ogeom_bail!(
89 Construction,
90 "a face handle resolved to {} faces through the canonical \
91 rebuild; the reference is ambiguous",
92 traced.len()
93 ),
94 })
95 .collect::<OgeomResult<Vec<Shape>>>()?;
96 Ok((baked.shape, mapped, Some(baked.history)))
97}
98
99pub fn offset_shape(
108 model: &mut Model,
109 solid: &Shape,
110 offset: f64,
111 tol: Tolerances,
112) -> OgeomResult<Built> {
113 if !offset.is_finite() || offset.abs() <= tol.confusion() {
114 ogeom_bail!(Construction, "an offset of {offset} moves nothing");
115 }
116 let (canonical, _, prefix) = canonical_input(model, solid, &[], tol)?;
117 if let Some(prefix) = prefix {
118 let mut out = offset_shape(model, &canonical, offset, tol)?;
119 out.history = prefix.then(&out.history);
120 return Ok(out);
121 }
122 rebuilt(model, solid, &|_| offset, &|_| None, tol)
123}
124
125pub fn make_thick_solid(
146 model: &mut Model,
147 solid: &Shape,
148 removed: &[Shape],
149 thickness: f64,
150 tol: Tolerances,
151) -> OgeomResult<Built> {
152 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
153 ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
154 }
155 let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
156 if let Some(prefix) = prefix {
157 let mut out = make_thick_solid(model, &canonical, &mapped, thickness, tol)?;
158 out.history = prefix.then(&out.history);
159 return Ok(out);
160 }
161 let outward_walls = thickness < 0.0;
164 let reach = thickness.abs();
165 let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
166 .iter()
167 .map(Shape::node)
168 .collect();
169 for face in removed {
170 if !own.contains(&face.node()) {
171 ogeom_bail!(Construction, "a removed face is not a face of the solid");
172 }
173 }
174
175 let mut tangent_opening = false;
176 for face in removed {
177 if has_tangent_neighbour(model, solid, face, tol)? {
178 tangent_opening = true;
179 break;
180 }
181 }
182 if !tangent_opening {
183 let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
184 let moved = rebuilt(
185 model,
186 solid,
187 &|face| {
188 if skip.contains(&face.node()) {
189 0.0
190 } else if outward_walls {
191 reach
192 } else {
193 -reach
194 }
195 },
196 &|_| None,
197 tol,
198 )?;
199 let mut result = if outward_walls {
204 ogeom_bool::cut(model, &moved.shape, solid, tol)?
205 } else {
206 ogeom_bool::cut(model, solid, &moved.shape, tol)?
207 };
208 for face in removed {
209 result.history.delete(face);
210 }
211 return Ok(result);
212 }
213
214 let displaced = if outward_walls { reach } else { -reach };
218 let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
219 let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
220 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
221 ogeom_bail!(Construction, "face node holds no face data");
222 };
223 let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
224 ogeom_bail!(
225 Construction,
226 "a tangent opening must be planar; a curved opening needs \
227 the general rebuild; see docs/PARITY.md, offset.shell-thicken"
228 );
229 };
230 let mut normal = p.plane().normal().vector();
231 if face.orientation() == Orientation::Reversed {
232 normal = -normal;
233 }
234 Ok(normal)
235 };
236 let mut result = if outward_walls {
237 let mut tool = solid.clone();
240 for face in removed {
241 let outward = opening_normal(model, face)?;
242 let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
243 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
244 }
245 ogeom_bool::cut(model, &moved.shape, &tool, tol)?
246 } else {
247 let mut tool = moved.shape.clone();
248 for face in removed {
249 let outward = opening_normal(model, face)?;
250 let [image] = moved.history.modified(face) else {
251 ogeom_bail!(Construction, "a removed face has no single cavity image");
252 };
253 let punch =
254 ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
255 tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
256 }
257 ogeom_bool::cut(model, solid, &tool, tol)?
258 };
259 for face in removed {
260 result.history.delete(face);
261 }
262 Ok(result)
263}
264
265fn has_tangent_neighbour(
267 model: &Model,
268 solid: &Shape,
269 face: &Shape,
270 tol: Tolerances,
271) -> OgeomResult<bool> {
272 use ogeom_geom::Surface as _;
273
274 let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
275 .iter()
276 .map(Shape::node)
277 .collect();
278 let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
279 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
280 ogeom_bail!(Construction, "face node holds no face data");
281 };
282 let Some(surface) = model.geometry().surface(data.surface) else {
283 ogeom_bail!(Dangling, "face refers to a surface not in this model");
284 };
285 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
286 if projection.distance > tol.confusion() * 100.0 {
287 return Ok(None);
288 }
289 let (u, v) = projection.parameters;
290 let (du, dv) = surface.d1_at(u, v, tol)?;
291 let n = du.cross(dv);
292 let m = n.magnitude();
293 if m <= tol.confusion() {
294 return Ok(None);
295 }
296 Ok(Some(n / m))
297 };
298 for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
299 if other.node() == face.node() {
300 continue;
301 }
302 for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
303 if !own_edges.contains(&edge.node()) {
304 continue;
305 }
306 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
307 continue;
308 };
309 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
310 continue;
311 };
312 let Some(geometry) = model.geometry().curve(*curve) else {
313 continue;
314 };
315 let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
316 let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
317 else {
318 continue;
319 };
320 if a.cross(b).magnitude() <= 1e-6 {
321 return Ok(true);
322 }
323 }
324 }
325 Ok(false)
326}
327
328struct Prepared {
330 shape: Shape,
331 surface: SurfaceGeometry,
333 amount: f64,
335 sign: f64,
338 rings: Option<[Shape; 2]>,
340}
341
342pub(crate) fn rebuilt(
363 model: &mut Model,
364 solid: &Shape,
365 amount_of: &dyn Fn(&Shape) -> f64,
366 instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
367 tol: Tolerances,
368) -> OgeomResult<Built> {
369 use ogeom_geom::Surface as _;
370 let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
371
372 let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
374 for face in &faces {
375 let amount = amount_of(face);
376 let Some(node) = model.node(face) else {
377 ogeom_bail!(Dangling, "face is not in this model");
378 };
379 let NodeData::Face(data) = node.data() else {
380 ogeom_bail!(Construction, "face node holds no face data");
381 };
382 let Some(surface) = model.geometry().surface(data.surface) else {
383 ogeom_bail!(Dangling, "face refers to a surface not in this model");
384 };
385 let sign = if face.orientation() == Orientation::Reversed {
386 -1.0
387 } else {
388 1.0
389 };
390 let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
391 let mut counts: HashMap<TShapeId, usize> = HashMap::new();
392 for e in &edges {
393 *counts.entry(e.node()).or_insert(0) += 1;
394 }
395 let has_seam = counts.values().any(|c| *c >= 2);
396 let closed_rings: Vec<Shape> = edges
397 .iter()
398 .filter(|e| {
399 edge_vertices(model, e)
400 .ok()
401 .flatten()
402 .is_some_and(|(a, b)| a.node() == b.node())
403 })
404 .cloned()
405 .collect();
406
407 let grow = amount.abs() * 4.0 + 1.0;
408 let replacement = instead_of(face);
409 let moved: SurfaceGeometry = if let Some(given) = replacement {
410 given
411 } else if amount == 0.0 {
412 surface.clone()
416 } else {
417 match surface {
418 SurfaceGeometry::Plane(p) => {
419 let plane = p.plane();
420 let ((u0, u1), (v0, v1)) = surface.domain();
421 let shifted = Plane::new(Frame::new(
422 plane.origin() + plane.normal().vector() * (sign * amount),
423 plane.normal(),
424 plane.frame().x(),
425 tol,
426 )?);
427 PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
428 .into()
429 }
430 SurfaceGeometry::Cylinder(c) => {
431 let cylinder = c.cylinder();
432 let grown = sign.mul_add(amount, cylinder.radius());
433 if grown <= tol.confusion() {
434 ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
435 }
436 let (_, (v0, v1)) = surface.domain();
437 CylinderSurface::new(
438 Cylinder::new(cylinder.frame(), grown, tol)?,
439 (v0 - grow, v1 + grow),
440 )?
441 .into()
442 }
443 SurfaceGeometry::Sphere(sp) => {
444 let sphere = sp.sphere();
445 let grown = sign.mul_add(amount, sphere.radius());
446 if grown <= tol.confusion() {
447 ogeom_bail!(Construction, "the offset consumes the sphere's radius");
448 }
449 ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
450 sphere.centre(),
451 grown,
452 tol,
453 )?)
454 .into()
455 }
456 SurfaceGeometry::Torus(t) => {
457 let torus = t.torus();
458 let grown = sign.mul_add(amount, torus.minor_radius());
459 if grown <= tol.confusion() {
460 ogeom_bail!(Construction, "the offset consumes the torus's tube");
461 }
462 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
463 torus.frame(),
464 torus.major_radius(),
465 grown,
466 tol,
467 )?)
468 .into()
469 }
470 SurfaceGeometry::Cone(co) => {
471 let cone = co.cone();
472 let grown = (sign * amount / cone.half_angle().cos())
475 .mul_add(1.0, cone.reference_radius());
476 if grown <= tol.confusion() {
477 ogeom_bail!(Construction, "the offset consumes the cone's throat");
478 }
479 let (_, (v0, v1)) = surface.domain();
480 ogeom_geom::ConeSurface::new(
481 ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
482 (v0 - grow, v1 + grow),
483 )?
484 .into()
485 }
486 _ => ogeom_bail!(
487 Construction,
488 "offsetting a face on this surface needs a construction \
489 the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
490 ),
491 }
492 };
493 let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
497 prepared.push(Prepared {
498 shape: face.clone(),
499 surface: moved,
500 amount,
501 sign,
502 rings: if has_seam && closed_rings.len() == 2 && !fitted_support {
503 Some([closed_rings[0].clone(), closed_rings[1].clone()])
504 } else {
505 None
506 },
507 });
508 }
509
510 let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
512 for (fi, face) in faces.iter().enumerate() {
513 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
514 let entry = edge_faces.entry(e.node()).or_default();
515 if !entry.contains(&fi) {
516 entry.push(fi);
517 }
518 }
519 }
520
521 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
525 let face = &faces[fi];
526 let Some(node) = model.node(face) else {
527 ogeom_bail!(Dangling, "face is not in this model");
528 };
529 let NodeData::Face(data) = node.data() else {
530 ogeom_bail!(Construction, "face node holds no face data");
531 };
532 let Some(surface) = model.geometry().surface(data.surface) else {
533 ogeom_bail!(Dangling, "face refers to a surface not in this model");
534 };
535 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
536 if projection.distance > tol.confusion() * 100.0 {
537 return Ok(None);
538 }
539 let (u, v) = projection.parameters;
540 let (du, dv) = surface.d1_at(u, v, tol)?;
541 let n = du.cross(dv);
542 let m = n.magnitude();
543 if m <= tol.confusion() {
544 return Ok(None);
545 }
546 let outward = n / m * prepared[fi].sign;
547 Ok(Some((outward, prepared[fi].amount)))
548 };
549
550 let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
554 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
555 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
556 continue;
557 };
558 let at = vertex.transform(model.datums())?.apply(data.point);
559 let mut seats: Vec<usize> = Vec::new();
560 for (fi, face) in faces.iter().enumerate() {
561 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
562 if v.node() == vertex.node() && !seats.contains(&fi) {
563 seats.push(fi);
564 }
565 }
566 }
567 if seats.is_empty() {
568 continue;
569 }
570 let mut normals: Vec<Vector> = Vec::new();
573 let mut amounts: Vec<f64> = Vec::new();
574 let mut kept: Vec<usize> = Vec::new();
575 for fi in &seats {
576 let Some((n, w)) = constraint(model, *fi, at)? else {
577 continue;
578 };
579 if let Some(k) = normals
580 .iter()
581 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
582 {
583 if (amounts[k] - w).abs() > tol.confusion() {
584 ogeom_bail!(
585 Construction,
586 "two tangent faces move a shared vertex by different \
587 amounts; the offset tears it"
588 );
589 }
590 continue;
591 }
592 normals.push(n);
593 amounts.push(w);
594 kept.push(*fi);
595 }
596 if normals.is_empty() {
597 let mut apex: Option<Point> = None;
601 for fi in &seats {
602 let Some(node) = model.node(&faces[*fi]) else {
603 continue;
604 };
605 let NodeData::Face(data) = node.data() else {
606 continue;
607 };
608 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
609 else {
610 continue;
611 };
612 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
613 continue;
614 }
615 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
616 apex = Some(moved_cone.cone().apex());
617 break;
618 }
619 }
620 let Some(moved) = apex else {
621 ogeom_bail!(
622 Construction,
623 "a vertex with no seat the rebuild can read cannot be \
624 re-solved"
625 );
626 };
627 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
628 continue;
629 }
630 if normals.len() == 1 {
631 let moved = at + normals[0] * amounts[0];
636 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
637 continue;
638 }
639 if kept.len() == 2
645 && let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
646 {
647 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
648 continue;
649 }
650 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
651 for _ in 0..8 {
655 let mut ns: Vec<Vector> = Vec::new();
656 let mut rs: Vec<f64> = Vec::new();
657 for fi in &kept {
658 let projection =
659 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
660 let (u, v) = projection.parameters;
661 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
662 let n = du.cross(dv);
663 let m = n.magnitude();
664 if m <= tol.confusion() {
665 continue;
666 }
667 let n = n / m;
668 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
669 ns.push(n);
670 rs.push((moved - foot).dot(n));
671 }
672 if ns.len() < 2 {
673 break;
674 }
675 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
676 if worst <= tol.confusion() * 0.1 {
677 break;
678 }
679 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
680 moved += solve_corner(&ns, &step, tol)?;
681 }
682 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
683 }
684
685 let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
688 for face in &faces {
689 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
690 *edge_uses.entry(e.node()).or_insert(0) += 1;
691 }
692 }
693
694 let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
696 let mut history = History::new();
697 for edge in explore_unique(model, solid, ShapeType::Edge)? {
698 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
699 if sides.len() != 2 {
700 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
701 if let [fi] = sides.as_slice()
706 && let Some(built) =
707 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
708 {
709 history.modify(&edge, built.clone());
710 new_edges.insert(edge.node(), built);
711 }
712 continue;
713 }
714 let Some(built) =
717 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
718 else {
719 ogeom_bail!(
720 Construction,
721 "an edge with one face is neither a ring nor an apex; \
722 the offset cannot re-derive it"
723 );
724 };
725 history.modify(&edge, built.clone());
726 new_edges.insert(edge.node(), built);
727 continue;
728 }
729 let (curve, range) = {
730 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
731 ogeom_bail!(Construction, "edge node holds no edge data");
732 };
733 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
734 ogeom_bail!(Construction, "an edge has no curve to offset");
735 };
736 let Some(geometry) = model.geometry().curve(*curve) else {
737 ogeom_bail!(Dangling, "curve is not in this model");
738 };
739 (geometry.clone(), *range)
740 };
741 let forward = if edge.orientation() == Orientation::Reversed {
742 edge.reversed()
743 } else {
744 edge.clone()
745 };
746 let built = match &curve {
747 Curve::Line(_) => {
748 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
755 ogeom_bail!(Construction, "a straight edge has no vertices");
756 };
757 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
758 new_vertices.get(&sv.node()).cloned(),
759 new_vertices.get(&ev.node()).cloned(),
760 ) else {
761 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
762 };
763 if p_to.distance(p_from) <= tol.parametric() {
764 ogeom_bail!(Construction, "the offset collapses an edge");
765 }
766 let segment = LineCurve::segment(p_from, p_to, tol)?;
767 let (t0, t1) = segment.domain();
768 let moved: Curve = segment.into();
769 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
770 }
771 Curve::Circle(c)
772 if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
773 && !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
774 {
775 let circle = c.circle();
780 let found = ogeom_intersect::intersect_surfaces(
781 &prepared[sides[0]].surface,
782 &prepared[sides[1]].surface,
783 ogeom_intersect::IntersectOptions::default(),
784 tol,
785 )?;
786 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
787 ogeom_bail!(
788 Construction,
789 "the moved faces no longer meet along the edge they \
790 shared; the offset collapses it"
791 );
792 };
793 let mut best: Option<(ogeom_math::Circle, f64)> = None;
794 for section in &candidates {
795 let Curve::Circle(cc) = §ion.curve else {
796 continue;
797 };
798 let candidate = cc.circle();
799 let score = candidate.centre().distance(circle.centre())
800 + (candidate.radius() - circle.radius()).abs();
801 if best.as_ref().is_none_or(|(_, held)| score < *held) {
802 best = Some((candidate, score));
803 }
804 }
805 let Some((candidate, _)) = best else {
806 ogeom_bail!(
807 Construction,
808 "the moved faces meet along nothing circular where a \
809 circle was; the offset needs the general rebuild"
810 );
811 };
812 let reframed = ogeom_math::Circle::new(
813 Frame::new(
814 candidate.centre(),
815 circle.frame().z(),
816 circle.frame().x(),
817 tol,
818 )?,
819 candidate.radius(),
820 tol,
821 )?;
822 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
823 let closed = {
824 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
825 ogeom_bail!(Construction, "a ring has no vertex");
826 };
827 sv.node() == ev.node()
828 };
829 if closed {
830 make_edge(model, moved, range, tol)?.shape
831 } else {
832 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
833 ogeom_bail!(Construction, "an arc has no vertices");
834 };
835 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
836 new_vertices.get(&sv.node()).cloned(),
837 new_vertices.get(&ev.node()).cloned(),
838 ) else {
839 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
840 };
841 let angle_of = |p: Point| {
842 let l = reframed.frame().to_local(p);
843 l.y.atan2(l.x)
844 };
845 let tau = core::f64::consts::TAU;
846 let mut t0 = angle_of(p_from);
847 let mut t1 = angle_of(p_to);
848 while t0 < range.0 - core::f64::consts::PI {
850 t0 += tau;
851 }
852 while t0 > range.0 + core::f64::consts::PI {
853 t0 -= tau;
854 }
855 while t1 <= t0 + tol.parametric() {
856 t1 += tau;
857 }
858 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
859 t1 -= tau;
860 }
861 if t1 <= t0 + tol.parametric() {
862 ogeom_bail!(Construction, "the offset collapses an arc");
863 }
864 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
865 }
866 }
867 _ => {
868 let unmoved = {
873 let mut worst = 0.0_f64;
874 'probe: for i in 0..9 {
875 #[allow(clippy::cast_precision_loss)]
876 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
877 let p = curve.point_at(t, tol)?;
878 for side in [sides[0], sides[1]] {
879 let Ok(near) =
880 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
881 else {
882 worst = f64::INFINITY;
883 break 'probe;
884 };
885 worst = worst.max(near.distance);
886 }
887 }
888 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
892 };
893 if let Some(worst) = unmoved {
894 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
895 ogeom_bail!(Construction, "an edge has no vertices");
896 };
897 let closed = sv.node() == ev.node();
898 let built = if closed {
899 match new_vertices.get(&sv.node()).cloned() {
902 Some((v_at, p_at)) => {
903 let gap = curve.point_at(range.0, tol)?.distance(p_at);
904 if gap > tol.confusion() {
905 model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
906 }
907 make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
908 .shape
909 }
910 None => make_edge(model, curve.clone(), range, tol)?.shape,
911 }
912 } else {
913 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
914 new_vertices.get(&sv.node()).cloned(),
915 new_vertices.get(&ev.node()).cloned(),
916 ) else {
917 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
918 };
919 let gap = curve
923 .point_at(range.0, tol)?
924 .distance(p_from)
925 .min(curve.point_at(range.0, tol)?.distance(p_to))
926 .max(
927 curve
928 .point_at(range.1, tol)?
929 .distance(p_to)
930 .min(curve.point_at(range.1, tol)?.distance(p_from)),
931 );
932 if gap > tol.confusion() {
933 for v in [&v_from, &v_to] {
934 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
935 }
936 }
937 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
938 };
939 if worst > tol.confusion()
940 && let Some(node) = model.node_mut(&built)
941 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
942 {
943 data.tolerance = data.tolerance.widen_to(worst);
944 }
945 history.modify(&edge, built.clone());
946 new_edges.insert(edge.node(), built);
947 continue;
948 }
949 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
956 let found = ogeom_intersect::intersect_surfaces(
957 &prepared[sides[0]].surface,
958 &prepared[sides[1]].surface,
959 ogeom_intersect::IntersectOptions::default(),
960 tol,
961 )?;
962 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
963 ogeom_bail!(
964 Construction,
965 "the moved faces no longer meet along the edge they \
966 shared; the offset collapses it"
967 );
968 };
969 let mut best: Option<(Curve, f64, f64)> = None;
970 for section in candidates {
971 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
972 else {
973 continue;
974 };
975 if best
976 .as_ref()
977 .is_none_or(|(_, _, held)| projected.distance < *held)
978 {
979 best = Some((section.curve, section.tolerance, projected.distance));
980 }
981 }
982 let Some((moved, slop, _)) = best else {
983 ogeom_bail!(
984 Construction,
985 "the moved faces meet along nothing where the edge \
986 was; the offset collapses it"
987 );
988 };
989 let closed = {
990 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
991 ogeom_bail!(Construction, "an edge has no vertices");
992 };
993 sv.node() == ev.node()
994 };
995 let built = if closed {
996 let Some((sv, _)) = edge_vertices(model, &forward)? else {
1002 ogeom_bail!(Construction, "a ring has no vertex");
1003 };
1004 match (new_vertices.get(&sv.node()).cloned(), &moved) {
1005 (Some((v_at, p_at)), Curve::BSpline(spline)) => {
1006 let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
1007 let (lo, hi) = moved.domain();
1008 let seamed: Curve = if t.parameter > lo + tol.parametric()
1009 && t.parameter < hi - tol.parametric()
1010 {
1011 Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
1012 } else {
1013 moved.clone()
1014 };
1015 let seamed = {
1019 use ogeom_geom::Reversible as _;
1020 let (a, _) = seamed.domain();
1021 let old = curve.d1_at(range.0, tol)?;
1022 if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
1023 seamed.reversed()
1024 } else {
1025 seamed
1026 }
1027 };
1028 let miss = t.distance.max(slop);
1029 if miss > tol.confusion() {
1030 model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1031 }
1032 let window = seamed.domain();
1033 make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
1034 }
1035 _ => {
1036 let window = moved.domain();
1037 make_edge(model, moved, window, tol)?.shape
1038 }
1039 }
1040 } else {
1041 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
1042 ogeom_bail!(Construction, "an edge has no vertices");
1043 };
1044 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1045 new_vertices.get(&sv.node()).cloned(),
1046 new_vertices.get(&ev.node()).cloned(),
1047 ) else {
1048 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
1049 };
1050 if slop > tol.confusion() {
1053 for v in [&v_from, &v_to] {
1054 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
1055 }
1056 }
1057 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
1058 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
1059 if (tb - ta).abs() <= tol.parametric() {
1060 ogeom_bail!(Construction, "the offset collapses an edge");
1061 }
1062 let (moved, ta, tb) = if ta <= tb {
1066 (moved, ta, tb)
1067 } else {
1068 use ogeom_geom::Reversible as _;
1069 let (lo, hi) = moved.domain();
1070 (moved.reversed(), lo + hi - ta, lo + hi - tb)
1071 };
1072 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
1073 };
1074 if slop > tol.confusion()
1075 && let Some(node) = model.node_mut(&built)
1076 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1077 {
1078 data.tolerance = data.tolerance.widen_to(slop);
1079 }
1080 built
1081 }
1082 };
1083 history.modify(&edge, built.clone());
1084 new_edges.insert(edge.node(), built);
1085 }
1086
1087 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1090 for prep in &prepared {
1091 let built = if let Some(rings) = &prep.rings {
1092 let (Some(lo), Some(hi)) = (
1093 new_edges.get(&rings[0].node()),
1094 new_edges.get(&rings[1].node()),
1095 ) else {
1096 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1097 };
1098 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1099 if prep.shape.orientation() == Orientation::Reversed {
1100 band.reversed()
1101 } else {
1102 band
1103 }
1104 } else {
1105 let mut wires: Vec<Vec<Shape>> = Vec::new();
1106 let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1107 for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1108 let mut edges: Vec<Shape> = Vec::new();
1109 for used in model.ordered_children_of(&wire)? {
1112 *face_uses.entry(used.node()).or_insert(0) += 1;
1113 let Some(fresh) = new_edges.get(&used.node()) else {
1114 ogeom_bail!(Construction, "a face edge was not rebuilt");
1115 };
1116 edges.push(if used.orientation() == Orientation::Reversed {
1117 fresh.reversed()
1118 } else {
1119 fresh.clone()
1120 });
1121 }
1122 wires.push(edges);
1123 }
1124 let face = if face_uses.values().any(|c| *c >= 2) {
1125 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1130 } else {
1131 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1132 };
1133 if prep.shape.orientation() == Orientation::Reversed {
1134 face.reversed()
1135 } else {
1136 face
1137 }
1138 };
1139 history.modify(&prep.shape, built.clone());
1140 rebuilt_faces.push(built);
1141 }
1142
1143 let sewn = sew(model, &rebuilt_faces, tol)?;
1144 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1145 ogeom_bail!(Construction, "the offset solid did not close");
1146 }
1147 let outer = {
1151 let old_reversed = model
1152 .children_of(solid)?
1153 .first()
1154 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1155 if old_reversed {
1156 sewn.shells[0].reversed()
1157 } else {
1158 sewn.shells[0].clone()
1159 }
1160 };
1161 let built = make_solid(model, std::slice::from_ref(&outer))?;
1162
1163 let mut mass = None;
1171 for chord in [ogeom_mesh::Deflection::default().chord, 1e-4] {
1172 let deflection = ogeom_mesh::Deflection {
1173 chord,
1174 ..ogeom_mesh::Deflection::default()
1175 };
1176 if let Ok(props) = ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1177 mass = Some(props.mass);
1178 break;
1179 }
1180 }
1181 let Some(mass) = mass else {
1182 ogeom_bail!(
1183 Construction,
1184 "the offset solid's mesh does not close at any tried resolution"
1185 );
1186 };
1187 if !mass.is_finite() || mass <= tol.confusion() {
1188 ogeom_bail!(Construction, "the offset collapses the solid");
1189 }
1190
1191 history.modify(solid, built.shape.clone());
1192 Ok(Built::new(built.shape, history))
1193}
1194
1195fn rebuilt_seam_edge(
1207 model: &mut Model,
1208 edge: &Shape,
1209 prep: &Prepared,
1210 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1211 tol: Tolerances,
1212) -> OgeomResult<Option<Shape>> {
1213 use ogeom_geom::Curve2d as _;
1214
1215 let old_surface = {
1216 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1217 else {
1218 ogeom_bail!(Construction, "face node holds no face data");
1219 };
1220 data.surface
1221 };
1222 let found = {
1223 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1224 ogeom_bail!(Construction, "edge node holds no edge data");
1225 };
1226 let mut found = None;
1227 for repr in &data.representations {
1228 if let EdgeRepr::Seam {
1229 forward,
1230 surface,
1231 range,
1232 ..
1233 } = repr
1234 && *surface == old_surface
1235 {
1236 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1237 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1238 };
1239 found = Some(pcurve.point_at(range.0, tol)?.x);
1243 break;
1244 }
1245 }
1246 found
1247 };
1248 let Some(column) = found else {
1249 return Ok(None);
1250 };
1251 let Some((sv, ev)) = edge_vertices(model, edge)? else {
1252 ogeom_bail!(Construction, "a seam has no vertices");
1253 };
1254 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1255 new_vertices.get(&sv.node()).cloned(),
1256 new_vertices.get(&ev.node()).cloned(),
1257 ) else {
1258 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1259 };
1260 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1261 ogeom_bail!(
1262 Construction,
1263 "the moved surface's iso-curve has no closed form; no seam can \
1264 be rebuilt"
1265 );
1266 };
1267 let along = |p: Point| -> OgeomResult<f64> {
1271 match &curve {
1272 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1273 Curve::Circle(c) => {
1274 let local = c.circle().frame().to_local(p);
1275 let mut angle = local.y.atan2(local.x);
1276 if angle < 0.0 {
1277 angle += core::f64::consts::TAU;
1278 }
1279 Ok(angle)
1280 }
1281 _ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
1285 }
1286 };
1287 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1288 let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
1298 (tol.confusion() * 1e3).max(1e-4) * 4.0
1299 } else {
1300 tol.confusion() * 100.0
1301 };
1302 for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
1303 let off = curve.point_at(t, tol)?.distance(p);
1304 if off > slack {
1305 return Ok(None);
1306 }
1307 if off > tol.confusion() {
1310 model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
1311 }
1312 }
1313 Ok(Some(if t_start <= t_end {
1314 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1315 } else {
1316 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1320 .shape
1321 .reversed()
1322 }))
1323}
1324
1325fn assembled_with_seam(
1333 model: &mut Model,
1334 prep: &Prepared,
1335 wires: &[Vec<Shape>],
1336 new_edges: &HashMap<TShapeId, Shape>,
1337 tol: Tolerances,
1338) -> OgeomResult<Shape> {
1339 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1340 for edges in wires {
1341 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1342 }
1343 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1344 let new_surface = {
1345 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1346 ogeom_bail!(Construction, "the face just built holds no face data");
1347 };
1348 data.surface
1349 };
1350 let old_surface = {
1351 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1352 else {
1353 ogeom_bail!(Construction, "face node holds no face data");
1354 };
1355 data.surface
1356 };
1357
1358 let mut done: Vec<TShapeId> = Vec::new();
1359 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1360 if done.contains(&used.node()) {
1361 continue;
1362 }
1363 done.push(used.node());
1364 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1365 ogeom_bail!(Construction, "a face edge was not rebuilt");
1366 };
1367 let (fresh_curve, fresh_range) = {
1368 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1369 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1370 };
1371 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1372 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1373 };
1374 let Some(geometry) = model.geometry().curve(*curve) else {
1375 ogeom_bail!(Dangling, "curve is not in this model");
1376 };
1377 (geometry.clone(), *range)
1378 };
1379 let columns = {
1380 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1381 ogeom_bail!(Construction, "edge node holds no edge data");
1382 };
1383 let mut columns = None;
1384 for repr in &data.representations {
1385 if let EdgeRepr::Seam {
1386 forward,
1387 reversed,
1388 surface,
1389 range,
1390 ..
1391 } = repr
1392 && *surface == old_surface
1393 {
1394 use ogeom_geom::Curve2d as _;
1395 let (Some(f), Some(r)) = (
1396 model.geometry().pcurve(*forward),
1397 model.geometry().pcurve(*reversed),
1398 ) else {
1399 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1400 };
1401 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1402 break;
1403 }
1404 }
1405 columns
1406 };
1407 if let Some((forward_col, reversed_col)) = columns {
1408 let rows = match &prep.surface {
1411 SurfaceGeometry::Cone(c) => {
1412 let cos = c.cone().half_angle().cos();
1413 (fresh_range.0 * cos, fresh_range.1 * cos)
1414 }
1415 _ => fresh_range,
1416 };
1417 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1418 Ok(ogeom_geom::Line2d::over(
1419 ogeom_math::Axis2::new(
1420 ogeom_math::Point2::new(u, 0.0),
1421 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1422 ),
1423 rows.0 - 1.0,
1424 rows.1 + 1.0,
1425 )?
1426 .into())
1427 };
1428 ogeom_algo::attach_seam(
1429 model,
1430 &fresh,
1431 column(forward_col)?,
1432 column(reversed_col)?,
1433 new_surface,
1434 ogeom_topo::Location::identity(),
1435 rows,
1436 )?;
1437 } else {
1438 let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
1442 Some(exact) => exact,
1443 None => {
1444 let (fitted, _, _, worst_off, _) =
1445 ogeom_algo::pcurve_fit::fit_projected_pcurve(
1446 &fresh_curve,
1447 fresh_range,
1448 &prep.surface,
1449 tol,
1450 )?;
1451 if worst_off > tol.confusion() {
1452 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
1456 model.widen(&fresh, widened)?;
1457 if let Some((a, b)) = edge_vertices(model, &fresh)? {
1458 model.widen(&a, widened)?;
1459 model.widen(&b, widened)?;
1460 }
1461 }
1462 fitted
1463 }
1464 };
1465 ogeom_algo::attach_pcurve(
1466 model,
1467 &fresh,
1468 pcurve,
1469 new_surface,
1470 ogeom_topo::Location::identity(),
1471 fresh_range,
1472 )?;
1473 }
1474 }
1475 Ok(face)
1476}
1477
1478fn seam_end(
1483 model: &Model,
1484 faces: &[Shape],
1485 prepared: &[Prepared],
1486 vertex: &Shape,
1487 kept: &[usize],
1488 at: Point,
1489 tol: Tolerances,
1490) -> OgeomResult<Option<Point>> {
1491 use ogeom_geom::Curve2d as _;
1492 for (slot, &fi) in kept.iter().enumerate() {
1493 let other = kept[1 - slot];
1494 let face = &faces[fi];
1495 let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
1496 continue;
1497 };
1498 let old_surface = data.surface;
1499 let mut uses: HashMap<TShapeId, usize> = HashMap::new();
1500 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1501 *uses.entry(e.node()).or_insert(0) += 1;
1502 }
1503 for e in explore_unique(model, face, ShapeType::Edge)? {
1504 if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
1505 continue;
1506 }
1507 let Some((a, b)) = edge_vertices(model, &e)? else {
1508 continue;
1509 };
1510 if a.node() != vertex.node() && b.node() != vertex.node() {
1511 continue;
1512 }
1513 let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
1514 continue;
1515 };
1516 let mut column = None;
1517 for repr in &edata.representations {
1518 if let EdgeRepr::Seam {
1519 forward,
1520 surface,
1521 range,
1522 ..
1523 } = repr
1524 && *surface == old_surface
1525 && let Some(pcurve) = model.geometry().pcurve(*forward)
1526 {
1527 column = Some(pcurve.point_at(range.0, tol)?.x);
1528 break;
1529 }
1530 }
1531 let Some(column) = column else {
1532 continue;
1533 };
1534 let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
1535 else {
1536 continue;
1537 };
1538 let found = ogeom_intersect::intersect_curve_surface(
1539 &iso,
1540 &prepared[other].surface,
1541 ogeom_intersect::CurveSurfaceOptions::default(),
1542 tol,
1543 )?;
1544 let nearest = found
1545 .crossings
1546 .iter()
1547 .map(|hit| hit.point)
1548 .min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
1549 if let Some(p) = nearest {
1550 return Ok(Some(p));
1551 }
1552 }
1553 }
1554 Ok(None)
1555}
1556
1557fn rebuilt_lone_edge(
1564 model: &mut Model,
1565 edge: &Shape,
1566 sides: &[usize],
1567 constraint: &Displacement<'_>,
1568 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1569 tol: Tolerances,
1570) -> OgeomResult<Option<Shape>> {
1571 use ogeom_geom::Curve3d as _;
1572
1573 let (degenerate, curve) = {
1574 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1575 ogeom_bail!(Construction, "edge node holds no edge data");
1576 };
1577 let curve = data.curve3d().and_then(|repr| {
1578 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1579 return None;
1580 };
1581 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1582 });
1583 (data.degenerate, curve)
1584 };
1585 let Some((start, end)) = edge_vertices(model, edge)? else {
1586 ogeom_bail!(Construction, "a lone edge has no vertices");
1587 };
1588 if degenerate {
1589 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1591 ogeom_bail!(Construction, "an apex has no re-solved vertex");
1592 };
1593 let mut data = EdgeData::new();
1594 data.degenerate = true;
1595 return Ok(Some(
1596 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1597 ));
1598 }
1599 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1600 else {
1601 return Ok(None);
1602 };
1603 let circle = c.circle();
1604 let mut moved_points = Vec::with_capacity(3);
1605 for k in 0..3 {
1606 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1607 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1608 let p = Curve::Circle(c).point_at(t, tol)?;
1609 let Some((n, w)) = constraint(model, fi, p)? else {
1610 return Ok(None);
1611 };
1612 moved_points.push(p + n * w);
1613 }
1614 let centre = Point::from_vector(
1618 moved_points
1619 .iter()
1620 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
1621 / 3.0,
1622 );
1623 let radius = centre.distance(moved_points[0]);
1624 let reframed = ogeom_math::Circle::new(
1625 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
1626 radius,
1627 tol,
1628 )?;
1629 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1630 Ok(Some(make_edge(model, moved, range, tol)?.shape))
1631}
1632
1633fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
1634 let mut a = [[0.0_f64; 3]; 3];
1636 let mut b = [0.0_f64; 3];
1637 for (n, w) in normals.iter().zip(amounts) {
1638 let row = [n.x, n.y, n.z];
1639 for i in 0..3 {
1640 for j in 0..3 {
1641 a[i][j] += row[i] * row[j];
1642 }
1643 b[i] += row[i] * w;
1644 }
1645 }
1646 if normals.len() == 2 {
1650 let along = normals[0].cross(normals[1]);
1651 let m = along.magnitude();
1652 if m <= tol.angular() {
1653 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
1654 }
1655 let d = along / m;
1656 let row = [d.x, d.y, d.z];
1657 for i in 0..3 {
1658 for j in 0..3 {
1659 a[i][j] += row[i] * row[j];
1660 }
1661 }
1662 }
1663 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
1664 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
1665 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
1666 if det.abs() <= tol.angular() * tol.angular() {
1667 ogeom_bail!(
1668 Construction,
1669 "a corner's faces are too nearly parallel to re-solve"
1670 );
1671 }
1672 let inv = |r: usize, c: usize| -> f64 {
1673 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
1674 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
1675 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
1676 };
1677 let mut x = [0.0_f64; 3];
1678 for (i, xi) in x.iter_mut().enumerate() {
1679 for (j, bj) in b.iter().enumerate() {
1680 *xi += inv(i, j) * bj;
1681 }
1682 }
1683 Ok(Vector::new(x[0], x[1], x[2]))
1684}