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 — 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 — docs/PARITY.md, offset.shell-thicken"
490 ),
491 }
492 };
493 prepared.push(Prepared {
494 shape: face.clone(),
495 surface: moved,
496 amount,
497 sign,
498 rings: if has_seam && closed_rings.len() == 2 {
499 Some([closed_rings[0].clone(), closed_rings[1].clone()])
500 } else {
501 None
502 },
503 });
504 }
505
506 let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
508 for (fi, face) in faces.iter().enumerate() {
509 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
510 let entry = edge_faces.entry(e.node()).or_default();
511 if !entry.contains(&fi) {
512 entry.push(fi);
513 }
514 }
515 }
516
517 let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
521 let face = &faces[fi];
522 let Some(node) = model.node(face) else {
523 ogeom_bail!(Dangling, "face is not in this model");
524 };
525 let NodeData::Face(data) = node.data() else {
526 ogeom_bail!(Construction, "face node holds no face data");
527 };
528 let Some(surface) = model.geometry().surface(data.surface) else {
529 ogeom_bail!(Dangling, "face refers to a surface not in this model");
530 };
531 let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
532 if projection.distance > tol.confusion() * 100.0 {
533 return Ok(None);
534 }
535 let (u, v) = projection.parameters;
536 let (du, dv) = surface.d1_at(u, v, tol)?;
537 let n = du.cross(dv);
538 let m = n.magnitude();
539 if m <= tol.confusion() {
540 return Ok(None);
541 }
542 let outward = n / m * prepared[fi].sign;
543 Ok(Some((outward, prepared[fi].amount)))
544 };
545
546 let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
550 for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
551 let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
552 continue;
553 };
554 let at = vertex.transform(model.datums())?.apply(data.point);
555 let mut seats: Vec<usize> = Vec::new();
556 for (fi, face) in faces.iter().enumerate() {
557 for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
558 if v.node() == vertex.node() && !seats.contains(&fi) {
559 seats.push(fi);
560 }
561 }
562 }
563 if seats.is_empty() {
564 continue;
565 }
566 let mut normals: Vec<Vector> = Vec::new();
569 let mut amounts: Vec<f64> = Vec::new();
570 let mut kept: Vec<usize> = Vec::new();
571 for fi in &seats {
572 let Some((n, w)) = constraint(model, *fi, at)? else {
573 continue;
574 };
575 if let Some(k) = normals
576 .iter()
577 .position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
578 {
579 if (amounts[k] - w).abs() > tol.confusion() {
580 ogeom_bail!(
581 Construction,
582 "two tangent faces move a shared vertex by different \
583 amounts; the offset tears it"
584 );
585 }
586 continue;
587 }
588 normals.push(n);
589 amounts.push(w);
590 kept.push(*fi);
591 }
592 if normals.is_empty() {
593 let mut apex: Option<Point> = None;
597 for fi in &seats {
598 let Some(node) = model.node(&faces[*fi]) else {
599 continue;
600 };
601 let NodeData::Face(data) = node.data() else {
602 continue;
603 };
604 let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
605 else {
606 continue;
607 };
608 if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
609 continue;
610 }
611 if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
612 apex = Some(moved_cone.cone().apex());
613 break;
614 }
615 }
616 let Some(moved) = apex else {
617 ogeom_bail!(
618 Construction,
619 "a vertex with no seat the rebuild can read cannot be \
620 re-solved"
621 );
622 };
623 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
624 continue;
625 }
626 if normals.len() == 1 {
627 let moved = at + normals[0] * amounts[0];
632 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
633 continue;
634 }
635 let mut moved = at + solve_corner(&normals, &amounts, tol)?;
636 for _ in 0..8 {
640 let mut ns: Vec<Vector> = Vec::new();
641 let mut rs: Vec<f64> = Vec::new();
642 for fi in &kept {
643 let projection =
644 ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
645 let (u, v) = projection.parameters;
646 let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
647 let n = du.cross(dv);
648 let m = n.magnitude();
649 if m <= tol.confusion() {
650 continue;
651 }
652 let n = n / m;
653 let foot = prepared[*fi].surface.point_at(u, v, tol)?;
654 ns.push(n);
655 rs.push((moved - foot).dot(n));
656 }
657 if ns.len() < 2 {
658 break;
659 }
660 let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
661 if worst <= tol.confusion() * 0.1 {
662 break;
663 }
664 let step: Vec<f64> = rs.iter().map(|r| -r).collect();
665 moved += solve_corner(&ns, &step, tol)?;
666 }
667 new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
668 }
669
670 let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
673 for face in &faces {
674 for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
675 *edge_uses.entry(e.node()).or_insert(0) += 1;
676 }
677 }
678
679 let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
681 let mut history = History::new();
682 for edge in explore_unique(model, solid, ShapeType::Edge)? {
683 let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
684 if sides.len() != 2 {
685 if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
686 if let [fi] = sides.as_slice()
691 && let Some(built) =
692 rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
693 {
694 history.modify(&edge, built.clone());
695 new_edges.insert(edge.node(), built);
696 }
697 continue;
698 }
699 let Some(built) =
702 rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
703 else {
704 ogeom_bail!(
705 Construction,
706 "an edge with one face is neither a ring nor an apex; \
707 the offset cannot re-derive it"
708 );
709 };
710 history.modify(&edge, built.clone());
711 new_edges.insert(edge.node(), built);
712 continue;
713 }
714 let (curve, range) = {
715 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
716 ogeom_bail!(Construction, "edge node holds no edge data");
717 };
718 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
719 ogeom_bail!(Construction, "an edge has no curve to offset");
720 };
721 let Some(geometry) = model.geometry().curve(*curve) else {
722 ogeom_bail!(Dangling, "curve is not in this model");
723 };
724 (geometry.clone(), *range)
725 };
726 let forward = if edge.orientation() == Orientation::Reversed {
727 edge.reversed()
728 } else {
729 edge.clone()
730 };
731 let built = match &curve {
732 Curve::Line(_) => {
733 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
740 ogeom_bail!(Construction, "a straight edge has no vertices");
741 };
742 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
743 new_vertices.get(&sv.node()).cloned(),
744 new_vertices.get(&ev.node()).cloned(),
745 ) else {
746 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
747 };
748 if p_to.distance(p_from) <= tol.parametric() {
749 ogeom_bail!(Construction, "the offset collapses an edge");
750 }
751 let segment = LineCurve::segment(p_from, p_to, tol)?;
752 let (t0, t1) = segment.domain();
753 let moved: Curve = segment.into();
754 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
755 }
756 Curve::Circle(c) => {
757 let circle = c.circle();
760 let found = ogeom_intersect::intersect_surfaces(
761 &prepared[sides[0]].surface,
762 &prepared[sides[1]].surface,
763 ogeom_intersect::IntersectOptions::default(),
764 tol,
765 )?;
766 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
767 ogeom_bail!(
768 Construction,
769 "the moved faces no longer meet along the edge they \
770 shared; the offset collapses it"
771 );
772 };
773 let mut best: Option<(ogeom_math::Circle, f64)> = None;
774 for section in &candidates {
775 let Curve::Circle(cc) = §ion.curve else {
776 continue;
777 };
778 let candidate = cc.circle();
779 let score = candidate.centre().distance(circle.centre())
780 + (candidate.radius() - circle.radius()).abs();
781 if best.as_ref().is_none_or(|(_, held)| score < *held) {
782 best = Some((candidate, score));
783 }
784 }
785 let Some((candidate, _)) = best else {
786 ogeom_bail!(
787 Construction,
788 "the moved faces meet along nothing circular where a \
789 circle was; the offset needs the general rebuild"
790 );
791 };
792 let reframed = ogeom_math::Circle::new(
793 Frame::new(
794 candidate.centre(),
795 circle.frame().z(),
796 circle.frame().x(),
797 tol,
798 )?,
799 candidate.radius(),
800 tol,
801 )?;
802 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
803 let closed = {
804 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
805 ogeom_bail!(Construction, "a ring has no vertex");
806 };
807 sv.node() == ev.node()
808 };
809 if closed {
810 make_edge(model, moved, range, tol)?.shape
811 } else {
812 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
813 ogeom_bail!(Construction, "an arc has no vertices");
814 };
815 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
816 new_vertices.get(&sv.node()).cloned(),
817 new_vertices.get(&ev.node()).cloned(),
818 ) else {
819 ogeom_bail!(Construction, "an arc end has no re-solved vertex");
820 };
821 let angle_of = |p: Point| {
822 let l = reframed.frame().to_local(p);
823 l.y.atan2(l.x)
824 };
825 let tau = core::f64::consts::TAU;
826 let mut t0 = angle_of(p_from);
827 let mut t1 = angle_of(p_to);
828 while t0 < range.0 - core::f64::consts::PI {
830 t0 += tau;
831 }
832 while t0 > range.0 + core::f64::consts::PI {
833 t0 -= tau;
834 }
835 while t1 <= t0 + tol.parametric() {
836 t1 += tau;
837 }
838 if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
839 t1 -= tau;
840 }
841 if t1 <= t0 + tol.parametric() {
842 ogeom_bail!(Construction, "the offset collapses an arc");
843 }
844 make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
845 }
846 }
847 _ => {
848 let unmoved = {
853 let mut worst = 0.0_f64;
854 'probe: for i in 0..9 {
855 #[allow(clippy::cast_precision_loss)]
856 let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
857 let p = curve.point_at(t, tol)?;
858 for side in [sides[0], sides[1]] {
859 let Ok(near) =
860 ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
861 else {
862 worst = f64::INFINITY;
863 break 'probe;
864 };
865 worst = worst.max(near.distance);
866 }
867 }
868 (worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
872 };
873 if let Some(worst) = unmoved {
874 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
875 ogeom_bail!(Construction, "an edge has no vertices");
876 };
877 let closed = sv.node() == ev.node();
878 let built = if closed {
879 make_edge(model, curve.clone(), range, tol)?.shape
880 } else {
881 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
882 new_vertices.get(&sv.node()).cloned(),
883 new_vertices.get(&ev.node()).cloned(),
884 ) else {
885 ogeom_bail!(Construction, "an edge end has no re-solved vertex");
886 };
887 let gap = curve
891 .point_at(range.0, tol)?
892 .distance(p_from)
893 .min(curve.point_at(range.0, tol)?.distance(p_to))
894 .max(
895 curve
896 .point_at(range.1, tol)?
897 .distance(p_to)
898 .min(curve.point_at(range.1, tol)?.distance(p_from)),
899 );
900 if gap > tol.confusion() {
901 for v in [&v_from, &v_to] {
902 model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
903 }
904 }
905 make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
906 };
907 if worst > tol.confusion()
908 && let Some(node) = model.node_mut(&built)
909 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
910 {
911 data.tolerance = data.tolerance.widen_to(worst);
912 }
913 history.modify(&edge, built.clone());
914 new_edges.insert(edge.node(), built);
915 continue;
916 }
917 let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
924 let found = ogeom_intersect::intersect_surfaces(
925 &prepared[sides[0]].surface,
926 &prepared[sides[1]].surface,
927 ogeom_intersect::IntersectOptions::default(),
928 tol,
929 )?;
930 let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
931 ogeom_bail!(
932 Construction,
933 "the moved faces no longer meet along the edge they \
934 shared; the offset collapses it"
935 );
936 };
937 let mut best: Option<(Curve, f64, f64)> = None;
938 for section in candidates {
939 let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
940 else {
941 continue;
942 };
943 if best
944 .as_ref()
945 .is_none_or(|(_, _, held)| projected.distance < *held)
946 {
947 best = Some((section.curve, section.tolerance, projected.distance));
948 }
949 }
950 let Some((moved, slop, _)) = best else {
951 ogeom_bail!(
952 Construction,
953 "the moved faces meet along nothing where the edge \
954 was; the offset collapses it"
955 );
956 };
957 let closed = {
958 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
959 ogeom_bail!(Construction, "an edge has no vertices");
960 };
961 sv.node() == ev.node()
962 };
963 let built = if closed {
964 let window = moved.domain();
965 make_edge(model, moved, window, tol)?.shape
966 } else {
967 let Some((sv, ev)) = edge_vertices(model, &forward)? else {
968 ogeom_bail!(Construction, "an edge has no vertices");
969 };
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 if slop > tol.confusion() {
979 for v in [&v_from, &v_to] {
980 model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
981 }
982 }
983 let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
984 let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
985 if (tb - ta).abs() <= tol.parametric() {
986 ogeom_bail!(Construction, "the offset collapses an edge");
987 }
988 let (moved, ta, tb) = if ta <= tb {
992 (moved, ta, tb)
993 } else {
994 use ogeom_geom::Reversible as _;
995 let (lo, hi) = moved.domain();
996 (moved.reversed(), lo + hi - ta, lo + hi - tb)
997 };
998 make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
999 };
1000 if slop > tol.confusion()
1001 && let Some(node) = model.node_mut(&built)
1002 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1003 {
1004 data.tolerance = data.tolerance.widen_to(slop);
1005 }
1006 built
1007 }
1008 };
1009 history.modify(&edge, built.clone());
1010 new_edges.insert(edge.node(), built);
1011 }
1012
1013 let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
1016 for prep in &prepared {
1017 let built = if let Some(rings) = &prep.rings {
1018 let (Some(lo), Some(hi)) = (
1019 new_edges.get(&rings[0].node()),
1020 new_edges.get(&rings[1].node()),
1021 ) else {
1022 ogeom_bail!(Construction, "a band's ring was not rebuilt");
1023 };
1024 let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
1025 if prep.shape.orientation() == Orientation::Reversed {
1026 band.reversed()
1027 } else {
1028 band
1029 }
1030 } else {
1031 let mut wires: Vec<Vec<Shape>> = Vec::new();
1032 let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
1033 for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
1034 let mut edges: Vec<Shape> = Vec::new();
1035 for used in model.ordered_children_of(&wire)? {
1038 *face_uses.entry(used.node()).or_insert(0) += 1;
1039 let Some(fresh) = new_edges.get(&used.node()) else {
1040 ogeom_bail!(Construction, "a face edge was not rebuilt");
1041 };
1042 edges.push(if used.orientation() == Orientation::Reversed {
1043 fresh.reversed()
1044 } else {
1045 fresh.clone()
1046 });
1047 }
1048 wires.push(edges);
1049 }
1050 let face = if face_uses.values().any(|c| *c >= 2) {
1051 assembled_with_seam(model, prep, &wires, &new_edges, tol)?
1056 } else {
1057 make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
1058 };
1059 if prep.shape.orientation() == Orientation::Reversed {
1060 face.reversed()
1061 } else {
1062 face
1063 }
1064 };
1065 history.modify(&prep.shape, built.clone());
1066 rebuilt_faces.push(built);
1067 }
1068
1069 let sewn = sew(model, &rebuilt_faces, tol)?;
1070 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1071 ogeom_bail!(Construction, "the offset solid did not close");
1072 }
1073 let outer = {
1077 let old_reversed = model
1078 .children_of(solid)?
1079 .first()
1080 .is_some_and(|s| s.orientation() == Orientation::Reversed);
1081 if old_reversed {
1082 sewn.shells[0].reversed()
1083 } else {
1084 sewn.shells[0].clone()
1085 }
1086 };
1087 let built = make_solid(model, std::slice::from_ref(&outer))?;
1088
1089 let mut mass = None;
1097 for chord in [ogeom_mesh::Deflection::default().chord, 1e-4] {
1098 let deflection = ogeom_mesh::Deflection {
1099 chord,
1100 ..ogeom_mesh::Deflection::default()
1101 };
1102 if let Ok(props) = ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
1103 mass = Some(props.mass);
1104 break;
1105 }
1106 }
1107 let Some(mass) = mass else {
1108 ogeom_bail!(
1109 Construction,
1110 "the offset solid's mesh does not close at any tried resolution"
1111 );
1112 };
1113 if !mass.is_finite() || mass <= tol.confusion() {
1114 ogeom_bail!(Construction, "the offset collapses the solid");
1115 }
1116
1117 history.modify(solid, built.shape.clone());
1118 Ok(Built::new(built.shape, history))
1119}
1120
1121fn rebuilt_seam_edge(
1133 model: &mut Model,
1134 edge: &Shape,
1135 prep: &Prepared,
1136 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1137 tol: Tolerances,
1138) -> OgeomResult<Option<Shape>> {
1139 use ogeom_geom::Curve2d as _;
1140
1141 let old_surface = {
1142 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1143 else {
1144 ogeom_bail!(Construction, "face node holds no face data");
1145 };
1146 data.surface
1147 };
1148 let found = {
1149 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1150 ogeom_bail!(Construction, "edge node holds no edge data");
1151 };
1152 let mut found = None;
1153 for repr in &data.representations {
1154 if let EdgeRepr::Seam {
1155 forward,
1156 surface,
1157 range,
1158 ..
1159 } = repr
1160 && *surface == old_surface
1161 {
1162 let Some(pcurve) = model.geometry().pcurve(*forward) else {
1163 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1164 };
1165 found = Some(pcurve.point_at(range.0, tol)?.x);
1169 break;
1170 }
1171 }
1172 found
1173 };
1174 let Some(column) = found else {
1175 return Ok(None);
1176 };
1177 let Some((sv, ev)) = edge_vertices(model, edge)? else {
1178 ogeom_bail!(Construction, "a seam has no vertices");
1179 };
1180 let (Some((v_from, p_from)), Some((v_to, p_to))) = (
1181 new_vertices.get(&sv.node()).cloned(),
1182 new_vertices.get(&ev.node()).cloned(),
1183 ) else {
1184 ogeom_bail!(Construction, "a seam end has no re-solved vertex");
1185 };
1186 let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
1187 ogeom_bail!(
1188 Construction,
1189 "the moved surface's iso-curve has no closed form; no seam can \
1190 be rebuilt"
1191 );
1192 };
1193 let along = |p: Point| -> OgeomResult<f64> {
1197 match &curve {
1198 Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
1199 Curve::Circle(c) => {
1200 let local = c.circle().frame().to_local(p);
1201 let mut angle = local.y.atan2(local.x);
1202 if angle < 0.0 {
1203 angle += core::f64::consts::TAU;
1204 }
1205 Ok(angle)
1206 }
1207 _ => ogeom_bail!(
1208 Construction,
1209 "the moved seam's iso-curve is neither straight nor circular"
1210 ),
1211 }
1212 };
1213 let (t_start, t_end) = (along(p_from)?, along(p_to)?);
1214 for (t, p) in [(t_start, p_from), (t_end, p_to)] {
1218 if curve.point_at(t, tol)?.distance(p) > tol.confusion() * 100.0 {
1219 return Ok(None);
1220 }
1221 }
1222 Ok(Some(if t_start <= t_end {
1223 make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
1224 } else {
1225 make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
1229 .shape
1230 .reversed()
1231 }))
1232}
1233
1234fn assembled_with_seam(
1242 model: &mut Model,
1243 prep: &Prepared,
1244 wires: &[Vec<Shape>],
1245 new_edges: &HashMap<TShapeId, Shape>,
1246 tol: Tolerances,
1247) -> OgeomResult<Shape> {
1248 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
1249 for edges in wires {
1250 rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
1251 }
1252 let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
1253 let new_surface = {
1254 let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
1255 ogeom_bail!(Construction, "the face just built holds no face data");
1256 };
1257 data.surface
1258 };
1259 let old_surface = {
1260 let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
1261 else {
1262 ogeom_bail!(Construction, "face node holds no face data");
1263 };
1264 data.surface
1265 };
1266
1267 let mut done: Vec<TShapeId> = Vec::new();
1268 for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
1269 if done.contains(&used.node()) {
1270 continue;
1271 }
1272 done.push(used.node());
1273 let Some(fresh) = new_edges.get(&used.node()).cloned() else {
1274 ogeom_bail!(Construction, "a face edge was not rebuilt");
1275 };
1276 let (fresh_curve, fresh_range) = {
1277 let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
1278 ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
1279 };
1280 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1281 ogeom_bail!(Construction, "a rebuilt edge has no curve");
1282 };
1283 let Some(geometry) = model.geometry().curve(*curve) else {
1284 ogeom_bail!(Dangling, "curve is not in this model");
1285 };
1286 (geometry.clone(), *range)
1287 };
1288 let columns = {
1289 let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
1290 ogeom_bail!(Construction, "edge node holds no edge data");
1291 };
1292 let mut columns = None;
1293 for repr in &data.representations {
1294 if let EdgeRepr::Seam {
1295 forward,
1296 reversed,
1297 surface,
1298 range,
1299 ..
1300 } = repr
1301 && *surface == old_surface
1302 {
1303 use ogeom_geom::Curve2d as _;
1304 let (Some(f), Some(r)) = (
1305 model.geometry().pcurve(*forward),
1306 model.geometry().pcurve(*reversed),
1307 ) else {
1308 ogeom_bail!(Dangling, "a seam pcurve is not in this model");
1309 };
1310 columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
1311 break;
1312 }
1313 }
1314 columns
1315 };
1316 if let Some((forward_col, reversed_col)) = columns {
1317 let rows = match &prep.surface {
1320 SurfaceGeometry::Cone(c) => {
1321 let cos = c.cone().half_angle().cos();
1322 (fresh_range.0 * cos, fresh_range.1 * cos)
1323 }
1324 _ => fresh_range,
1325 };
1326 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1327 Ok(ogeom_geom::Line2d::over(
1328 ogeom_math::Axis2::new(
1329 ogeom_math::Point2::new(u, 0.0),
1330 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1331 ),
1332 rows.0 - 1.0,
1333 rows.1 + 1.0,
1334 )?
1335 .into())
1336 };
1337 ogeom_algo::attach_seam(
1338 model,
1339 &fresh,
1340 column(forward_col)?,
1341 column(reversed_col)?,
1342 new_surface,
1343 ogeom_topo::Location::identity(),
1344 rows,
1345 )?;
1346 } else {
1347 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol)
1348 else {
1349 ogeom_bail!(
1350 Construction,
1351 "a moved face edge has no closed-form pcurve on its surface"
1352 );
1353 };
1354 ogeom_algo::attach_pcurve(
1355 model,
1356 &fresh,
1357 pcurve,
1358 new_surface,
1359 ogeom_topo::Location::identity(),
1360 fresh_range,
1361 )?;
1362 }
1363 }
1364 Ok(face)
1365}
1366
1367fn rebuilt_lone_edge(
1374 model: &mut Model,
1375 edge: &Shape,
1376 sides: &[usize],
1377 constraint: &Displacement<'_>,
1378 new_vertices: &HashMap<TShapeId, (Shape, Point)>,
1379 tol: Tolerances,
1380) -> OgeomResult<Option<Shape>> {
1381 use ogeom_geom::Curve3d as _;
1382
1383 let (degenerate, curve) = {
1384 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1385 ogeom_bail!(Construction, "edge node holds no edge data");
1386 };
1387 let curve = data.curve3d().and_then(|repr| {
1388 let EdgeRepr::Curve3d { curve, range, .. } = repr else {
1389 return None;
1390 };
1391 model.geometry().curve(*curve).cloned().map(|c| (c, *range))
1392 });
1393 (data.degenerate, curve)
1394 };
1395 let Some((start, end)) = edge_vertices(model, edge)? else {
1396 ogeom_bail!(Construction, "a lone edge has no vertices");
1397 };
1398 if degenerate {
1399 let Some((vertex, _)) = new_vertices.get(&start.node()) else {
1401 ogeom_bail!(Construction, "an apex has no re-solved vertex");
1402 };
1403 let mut data = EdgeData::new();
1404 data.degenerate = true;
1405 return Ok(Some(
1406 model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
1407 ));
1408 }
1409 let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
1410 else {
1411 return Ok(None);
1412 };
1413 let circle = c.circle();
1414 let mut moved_points = Vec::with_capacity(3);
1415 for k in 0..3 {
1416 #[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
1417 let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
1418 let p = Curve::Circle(c).point_at(t, tol)?;
1419 let Some((n, w)) = constraint(model, fi, p)? else {
1420 return Ok(None);
1421 };
1422 moved_points.push(p + n * w);
1423 }
1424 let centre = Point::from_vector(
1428 moved_points
1429 .iter()
1430 .fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
1431 / 3.0,
1432 );
1433 let radius = centre.distance(moved_points[0]);
1434 let reframed = ogeom_math::Circle::new(
1435 Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
1436 radius,
1437 tol,
1438 )?;
1439 let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
1440 Ok(Some(make_edge(model, moved, range, tol)?.shape))
1441}
1442
1443fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
1444 let mut a = [[0.0_f64; 3]; 3];
1446 let mut b = [0.0_f64; 3];
1447 for (n, w) in normals.iter().zip(amounts) {
1448 let row = [n.x, n.y, n.z];
1449 for i in 0..3 {
1450 for j in 0..3 {
1451 a[i][j] += row[i] * row[j];
1452 }
1453 b[i] += row[i] * w;
1454 }
1455 }
1456 if normals.len() == 2 {
1460 let along = normals[0].cross(normals[1]);
1461 let m = along.magnitude();
1462 if m <= tol.angular() {
1463 ogeom_bail!(Construction, "an edge between parallel faces has no corner");
1464 }
1465 let d = along / m;
1466 let row = [d.x, d.y, d.z];
1467 for i in 0..3 {
1468 for j in 0..3 {
1469 a[i][j] += row[i] * row[j];
1470 }
1471 }
1472 }
1473 let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
1474 - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
1475 + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
1476 if det.abs() <= tol.angular() * tol.angular() {
1477 ogeom_bail!(
1478 Construction,
1479 "a corner's faces are too nearly parallel to re-solve"
1480 );
1481 }
1482 let inv = |r: usize, c: usize| -> f64 {
1483 let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
1484 let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
1485 (a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
1486 };
1487 let mut x = [0.0_f64; 3];
1488 for (i, xi) in x.iter_mut().enumerate() {
1489 for (j, bj) in b.iter().enumerate() {
1490 *xi += inv(i, j) * bj;
1491 }
1492 }
1493 Ok(Vector::new(x[0], x[1], x[2]))
1494}