1use ogeom_core::FastMap;
18
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::{Curve, Curve3d, SurfaceGeometry};
21use ogeom_math::{Point, Point2};
22use ogeom_topo::{EdgeData, EdgeRepr, FaceData, Location, Model, Shape, ShapeType, VertexData};
23
24use crate::history::{Built, History};
25
26pub mod roles {
28 use ogeom_core::Role;
29
30 pub const EDGE_START: Role = Role::op_defined(0);
32 pub const EDGE_END: Role = Role::op_defined(1);
34}
35
36pub fn make_vertex(model: &mut Model, point: Point) -> Built {
38 Built::from_nothing(model.add_vertex(VertexData::new(point)))
39}
40
41pub fn make_edge(
54 model: &mut Model,
55 curve: Curve,
56 range: (f64, f64),
57 tol: Tolerances,
58) -> OgeomResult<Built> {
59 let (lo, hi) = range;
60 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
61 ogeom_bail!(Construction, "edge range [{lo}, {hi}] is empty");
62 }
63 let start = curve.point_at(lo, tol)?;
64 let end = curve.point_at(hi, tol)?;
65
66 let start_vertex = model.add_vertex(VertexData::new(start));
67 let end_vertex = if start.is_equal(end, tol) {
70 start_vertex.clone()
71 } else {
72 model.add_vertex(VertexData::new(end))
73 };
74
75 let id = model.geometry_mut().add_curve(curve);
76 let data = EdgeData::on_curve(id, Location::identity(), range);
77 let edge = model.add_edge(data, &[start_vertex.clone(), end_vertex.clone()])?;
78
79 model.set_derived(
80 &start_vertex,
81 std::slice::from_ref(&edge),
82 roles::EDGE_START,
83 )?;
84 if !end_vertex.is_same(&start_vertex) {
85 model.set_derived(&end_vertex, std::slice::from_ref(&edge), roles::EDGE_END)?;
86 }
87
88 let mut history = History::new();
89 history.generate(&edge, start_vertex);
90 if !end_vertex.is_same(&edge) {
91 history.generate(&edge, end_vertex);
92 }
93 Ok(Built::new(edge, history))
94}
95
96pub fn make_edge_between(
103 model: &mut Model,
104 curve: Curve,
105 range: (f64, f64),
106 from: &Shape,
107 to: &Shape,
108 tol: Tolerances,
109) -> OgeomResult<Built> {
110 for v in [from, to] {
111 if model.kind_of(v)? != ShapeType::Vertex {
112 ogeom_bail!(Construction, "an edge is bounded by vertices");
113 }
114 }
115 let ends = [(range.0, from), (range.1, to)];
119 for (parameter, vertex) in ends {
120 let Some(node) = model.node(vertex) else {
121 ogeom_bail!(Dangling, "vertex is not in this model");
122 };
123 let Some(data) = node.data().as_vertex() else {
124 ogeom_bail!(Construction, "vertex node holds no point");
125 };
126 let placed = vertex.transform(model.datums())?.apply(data.point);
132 let on_curve = curve.point_at(parameter, tol)?;
133 let reach = data.tolerance.get().max(tol.confusion());
134 if !on_curve.is_within(placed, reach) {
135 if std::env::var_os("OGEOM_DEBUG_EDGE").is_some() {
136 eprintln!(
137 "EDGE MISS range {range:?} at {parameter}: curve {on_curve:?} vertex {placed:?} kind {}",
138 match &curve {
139 Curve::Line(_) => "line",
140 Curve::Circle(_) => "circle",
141 Curve::BSpline(b) =>
142 if b.degree() == 1 {
143 "bspline-1"
144 } else {
145 "bspline"
146 },
147 _ => "other",
148 }
149 );
150 }
151 ogeom_bail!(
152 Construction,
153 "curve at {parameter} is {} from the vertex it should meet, \
154 outside its tolerance of {reach}",
155 on_curve.distance(placed)
156 );
157 }
158 }
159
160 let id = model.geometry_mut().add_curve(curve);
161 let data = EdgeData::on_curve(id, Location::identity(), range);
162 let edge = model.add_edge(data, &[from.clone(), to.clone()])?;
163 Ok(Built::from_nothing(edge))
164}
165
166pub fn edge_vertices(model: &Model, edge: &Shape) -> OgeomResult<Option<(Shape, Shape)>> {
177 if model.kind_of(edge)? != ShapeType::Edge {
178 ogeom_bail!(Construction, "expected an edge");
179 }
180 let bounds = model.children_of(edge)?;
181 let (first, last) = match bounds.len() {
182 0 => return Ok(None),
183 1 => (bounds[0].clone(), bounds[0].clone()),
184 _ => (bounds[0].clone(), bounds[bounds.len() - 1].clone()),
185 };
186 Ok(Some(
187 if edge.orientation() == ogeom_topo::Orientation::Reversed {
188 (last, first)
189 } else {
190 (first, last)
191 },
192 ))
193}
194
195pub fn placed_edge_curve(
210 model: &Model,
211 edge: &Shape,
212 tol: Tolerances,
213) -> OgeomResult<Option<(Curve, (f64, f64))>> {
214 use ogeom_geom::Transformable as _;
215 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
216 ogeom_bail!(Dangling, "edge is not in this model");
217 };
218 let Some(EdgeRepr::Curve3d {
219 curve,
220 location,
221 range,
222 }) = data.curve3d()
223 else {
224 return Ok(None);
225 };
226 let Some(stored) = model.geometry().curve(*curve) else {
227 ogeom_bail!(Dangling, "curve is not in this model");
228 };
229 if location.is_identity() && edge.location().is_identity() {
230 return Ok(Some((stored.clone(), *range)));
231 }
232 let placement = edge.transform(model.datums())? * location.composed(model.datums())?;
233 let placed = stored.transformed(&placement, tol)?;
234 let (from, to) = (stored.domain(), placed.domain());
235 let range = (
236 ogeom_geom::carried(range.0, from, to),
237 ogeom_geom::carried(range.1, from, to),
238 );
239 Ok(Some((placed, range)))
240}
241
242pub(crate) fn charted_edge_curve(
254 model: &Model,
255 edge: &Shape,
256 tol: Tolerances,
257) -> OgeomResult<(Curve, (f64, f64))> {
258 let Some((curve, range)) = placed_edge_curve(model, edge, tol)? else {
259 ogeom_bail!(Construction, "a face edge has no 3D curve");
260 };
261 let stored = model
262 .node(edge)
263 .and_then(|n| n.data().as_edge())
264 .and_then(EdgeData::curve3d)
265 .and_then(EdgeRepr::range);
266 if stored != Some(range) {
267 ogeom_bail!(
268 Construction,
269 "an edge placed with a scale runs on another parameter than its \
270 curve; bake its placement before charting it on a face"
271 );
272 }
273 Ok((curve, range))
274}
275
276pub fn make_polygon(
290 model: &mut Model,
291 points: &[Point],
292 closed: bool,
293 tol: Tolerances,
294) -> OgeomResult<Built> {
295 let least = if closed { 3 } else { 2 };
296 if points.len() < least {
297 ogeom_bail!(
298 Construction,
299 "a {} polygon needs at least {least} points, got {}",
300 if closed { "closed" } else { "open" },
301 points.len()
302 );
303 }
304 for i in 1..points.len() {
305 if points[i].is_equal(points[i - 1], tol) {
306 ogeom_bail!(
307 Construction,
308 "points {} and {i} coincide, so the edge between them has no \
309 length",
310 i - 1
311 );
312 }
313 }
314 if points[0].is_equal(points[points.len() - 1], tol) {
321 ogeom_bail!(
322 Construction,
323 "the first and last points coincide; pass `closed` and drop the \
324 repeat, or the wire gets two vertices in one place rather than a \
325 closed loop"
326 );
327 }
328
329 let mut vertices: Vec<Shape> = Vec::with_capacity(points.len());
330 for point in points {
331 vertices.push(model.add_vertex(VertexData::new(*point)));
332 }
333
334 let mut edges = Vec::with_capacity(points.len());
335 let segments = if closed {
336 points.len()
337 } else {
338 points.len() - 1
339 };
340 for i in 0..segments {
341 let (from, to) = (points[i], points[(i + 1) % points.len()]);
342 let curve: Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
343 edges.push(
344 make_edge_between(
345 model,
346 curve,
347 (0.0, from.distance(to)),
348 &vertices[i],
349 &vertices[(i + 1) % points.len()],
352 tol,
353 )?
354 .shape,
355 );
356 }
357 make_wire(model, &edges, tol)
358}
359
360pub fn find_plane(
375 model: &Model,
376 shape: &Shape,
377 tol: Tolerances,
378) -> OgeomResult<Option<ogeom_math::Plane>> {
379 let points = sample_shape(model, shape, tol)?;
380 if points.len() < 3 {
381 return Ok(None);
382 }
383 let Some((centroid, normal)) = crate::measure::least_squares_plane(&points, tol) else {
384 return Ok(None);
385 };
386 let reach = tol.confusion().max(
389 points
390 .iter()
391 .map(|p| normal.dot_vector(*p - centroid).abs())
392 .fold(0.0_f64, f64::max),
393 );
394 if reach > tol.confusion() {
395 return Ok(None);
396 }
397 Ok(Some(ogeom_math::Plane::new(ogeom_math::Frame::about(
398 centroid, normal,
399 ))))
400}
401
402fn sample_shape(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
405 const ALONG_EDGE: usize = 8;
408
409 let mut points = Vec::new();
410 for vertex in ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)? {
411 if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
412 points.push(vertex.transform(model.datums())?.apply(data.point));
413 }
414 }
415 for edge in ogeom_topo::explore_unique(model, shape, ShapeType::Edge)? {
416 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
417 continue;
418 };
419 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
420 continue;
421 };
422 let Some(geometry) = model.geometry().curve(*curve) else {
423 continue;
424 };
425 if geometry.kind() == ogeom_geom::CurveKind::Line {
426 continue;
427 }
428 let placement = edge.transform(model.datums())?;
429 for i in 1..ALONG_EDGE {
430 #[allow(clippy::cast_precision_loss)]
431 let t = i as f64 / ALONG_EDGE as f64;
432 let at = range.0 + (range.1 - range.0) * t;
433 points.push(placement.apply(geometry.point_at(at, tol)?));
434 }
435 }
436 Ok(points)
437}
438
439pub fn make_wire(model: &mut Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<Built> {
446 if edges.is_empty() {
447 ogeom_bail!(Construction, "a wire needs at least one edge");
448 }
449 check_connected(model, edges, tol)?;
450
451 let wire = model.add_wire(edges)?;
452 let mut history = History::new();
453 for edge in edges {
454 history.generate(edge, wire.clone());
458 }
459 Ok(Built::new(wire, history))
460}
461
462fn check_connected(model: &Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<()> {
464 let mut ends: Vec<Option<(Shape, Shape)>> = Vec::with_capacity(edges.len());
465 for edge in edges {
466 ends.push(edge_vertices(model, edge)?);
467 }
468 for i in 0..edges.len().saturating_sub(1) {
469 let (Some((_, end)), Some((next_start, _))) = (&ends[i], &ends[i + 1]) else {
470 ogeom_bail!(
473 Construction,
474 "edge {i} or {} has no bounding vertices, so the wire cannot be \
475 shown to connect",
476 i + 1
477 );
478 };
479 if !end.is_same(next_start)
480 && !model.same_position(end, next_start, tol)?
481 && !one_point(model, end, next_start, tol)?
482 {
483 if std::env::var("OGEOM_DEBUG_WIRE").is_ok()
484 && let (Some(a), Some(b)) = (
485 model.node(end).and_then(|n| n.data().as_vertex().cloned()),
486 model
487 .node(next_start)
488 .and_then(|n| n.data().as_vertex().cloned()),
489 )
490 {
491 eprintln!(
492 "WIRE GAP: {:?} (tol {:.2e}) vs {:?} (tol {:.2e}), gap {:.3e}",
493 a.point,
494 a.tolerance.get(),
495 b.point,
496 b.tolerance.get(),
497 a.point.distance(b.point)
498 );
499 }
500 ogeom_bail!(
501 Construction,
502 "edge {i} ends where edge {} does not begin; a wire whose edges \
503 do not meet leaves a gap in every face built on it",
504 i + 1
505 );
506 }
507 }
508 Ok(())
509}
510
511pub fn is_wire_closed(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<bool> {
517 let edges = model.children_of(wire)?;
518 let (Some(first), Some(last)) = (edges.first(), edges.last()) else {
519 return Ok(false);
520 };
521 let (Some((start, _)), Some((_, end))) =
522 (edge_vertices(model, first)?, edge_vertices(model, last)?)
523 else {
524 return Ok(false);
525 };
526 Ok(start.is_same(&end)
527 || model.same_position(&start, &end, tol)?
528 || one_point(model, &start, &end, tol)?)
529}
530
531pub(crate) fn one_point(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<bool> {
534 if a.node() != b.node() {
535 return Ok(false);
536 }
537 let Some(data) = model.node(a).and_then(|n| n.data().as_vertex()) else {
538 return Ok(false);
539 };
540 let pa = a.transform(model.datums())?.apply(data.point);
541 let pb = b.transform(model.datums())?.apply(data.point);
542 Ok(pa.distance(pb) <= data.tolerance.get().max(tol.confusion()))
543}
544
545pub(crate) fn walked_back(model: &mut Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
547 let edges = model.ordered_children_of(&wire.reversed())?;
548 Ok(make_wire(model, &edges, tol)?.shape)
549}
550
551pub(crate) fn wire_turn(
555 model: &Model,
556 wire: &Shape,
557 axis: ogeom_math::Vector,
558 tol: Tolerances,
559) -> OgeomResult<f64> {
560 edges_turn(model, &model.ordered_children_of(wire)?, axis, tol)
561}
562
563pub fn edges_turn(
572 model: &Model,
573 edges: &[Shape],
574 axis: ogeom_math::Vector,
575 tol: Tolerances,
576) -> OgeomResult<f64> {
577 let mut points: Vec<ogeom_math::Point> = Vec::new();
578 for edge in edges {
579 let Some(EdgeRepr::Curve3d { curve, range, .. }) = model
580 .node(edge)
581 .and_then(|n| n.data().as_edge())
582 .and_then(|d| d.curve3d())
583 else {
584 continue;
585 };
586 let Some(geometry) = model.geometry().curve(*curve) else {
587 continue;
588 };
589 let placement = edge.transform(model.datums())?;
590 const SAMPLES: u32 = 16;
591 for k in 0..SAMPLES {
592 let f = f64::from(k) / f64::from(SAMPLES);
593 let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
594 range.1 + (range.0 - range.1) * f
595 } else {
596 range.0 + (range.1 - range.0) * f
597 };
598 points.push(placement.apply(geometry.point_at(t, tol)?));
599 }
600 }
601 let Some(&anchor) = points.first() else {
604 return Ok(0.0);
605 };
606 let mut newell = ogeom_math::Vector::ZERO;
607 for i in 0..points.len() {
608 let (a, b) = (points[i], points[(i + 1) % points.len()]);
609 newell += (a - anchor).cross(b - anchor);
610 }
611 Ok(newell.dot(axis))
612}
613
614pub fn make_face(
626 model: &mut Model,
627 surface: SurfaceGeometry,
628 wires: &[Shape],
629 tol: Tolerances,
630) -> OgeomResult<Built> {
631 for (i, wire) in wires.iter().enumerate() {
632 if model.kind_of(wire)? != ShapeType::Wire {
633 ogeom_bail!(Construction, "a face is bounded by wires");
634 }
635 if !is_wire_closed(model, wire, tol)? {
636 ogeom_bail!(
637 Construction,
638 "wire {i} is open; an open boundary encloses no area"
639 );
640 }
641 }
642
643 let wound = wound_to_plane(model, &surface, wires, tol)?;
644 let id = model.geometry_mut().add_surface(surface);
645 let mut built = make_face_on(model, id, &wound, tol)?;
646 for (given, used) in wires.iter().zip(&wound) {
647 if !given.is_same(used) {
648 built.history.generate(given, built.shape.clone());
649 }
650 }
651 Ok(built)
652}
653
654fn wound_to_plane(
661 model: &mut Model,
662 surface: &SurfaceGeometry,
663 wires: &[Shape],
664 tol: Tolerances,
665) -> OgeomResult<Vec<Shape>> {
666 use ogeom_geom::Surface as _;
667 let SurfaceGeometry::Plane(plane) = surface else {
668 return Ok(wires.to_vec());
669 };
670 let normal = plane.normal_at(0.0, 0.0, tol)?.vector();
671 let mut wound = Vec::with_capacity(wires.len());
672 for (i, wire) in wires.iter().enumerate() {
673 let turn = wire_turn(model, wire, normal, tol)?;
674 let want = if i == 0 { 1.0 } else { -1.0 };
675 let against = turn * want < -tol.confusion() * tol.confusion();
678 wound.push(if against {
679 walked_back(model, wire, tol)?
680 } else {
681 wire.clone()
682 });
683 }
684 Ok(wound)
685}
686
687pub fn make_face_on(
699 model: &mut Model,
700 surface: ogeom_topo::SurfaceId,
701 wires: &[Shape],
702 tol: Tolerances,
703) -> OgeomResult<Built> {
704 for (i, wire) in wires.iter().enumerate() {
705 if model.kind_of(wire)? != ShapeType::Wire {
706 ogeom_bail!(Construction, "a face is bounded by wires");
707 }
708 if !is_wire_closed(model, wire, tol)? {
709 ogeom_bail!(
710 Construction,
711 "wire {i} is open; an open boundary encloses no area"
712 );
713 }
714 }
715
716 let data = FaceData::new(surface, Location::identity());
717 let face = model.add_face(data, wires)?;
718
719 let mut history = History::new();
720 for wire in wires {
721 history.generate(wire, face.clone());
722 }
723 Ok(Built::new(face, history))
724}
725
726pub fn make_face_with_pcurves(
746 model: &mut Model,
747 surface: SurfaceGeometry,
748 wires: &[Vec<Shape>],
749 tol: Tolerances,
750) -> OgeomResult<Built> {
751 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
752 for edges in wires {
753 rings.push(make_wire(model, edges, tol)?.shape);
754 }
755 let id = model.geometry_mut().add_surface(surface.clone());
756 let built = make_face_on(model, id, &rings, tol)?;
757 let surface_id = {
758 let Some(node) = model.node(&built.shape) else {
759 ogeom_bail!(Dangling, "the face just built is not in this model");
760 };
761 let ogeom_topo::NodeData::Face(data) = node.data() else {
762 ogeom_bail!(Construction, "the face holds no face data");
763 };
764 data.surface
765 };
766 use ogeom_geom::Curve2d as _;
774 use ogeom_geom::Surface as _;
775 let ((ua, ub), (va, vb)) = surface.domain();
776 let u_period = surface.is_periodic_u().then_some(ub - ua);
777 let v_period = surface.is_periodic_v().then_some(vb - va);
778 let mut done: Vec<Shape> = Vec::new();
779 for edges in wires {
780 let mut prev_end: Option<ogeom_math::Point2> = None;
781 for edge in edges {
782 let (curve, prange) = charted_edge_curve(model, edge, tol)?;
783 let mut pcurve = match ogeom_intersect::exact_pcurve_over(&curve, prange, &surface, tol)
784 {
785 Some(exact) => exact,
786 None => {
794 let (fitted, _, _, worst_off, _) =
795 crate::pcurve_fit::fit_projected_pcurve(&curve, prange, &surface, tol)?;
796 let gap = crate::pcurve_fit::lifted_gap(
797 (&curve, prange),
798 (&fitted, prange),
799 &surface,
800 false,
801 tol,
802 )?;
803 let worst_off = worst_off.max(gap);
804 if worst_off > tol.confusion() {
805 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
808 model.widen(edge, widened)?;
809 if let Some((a, b)) = edge_vertices(model, edge)? {
810 model.widen(&a, widened)?;
811 model.widen(&b, widened)?;
812 }
813 }
814 fitted
815 }
816 };
817 let (t_start, t_end) = if edge.orientation() == ogeom_topo::Orientation::Reversed {
818 (prange.1, prange.0)
819 } else {
820 (prange.0, prange.1)
821 };
822 if u_period.is_some() || v_period.is_some() {
823 let start = pcurve.point_at(t_start, tol)?;
824 if let Some(prev) = prev_end {
825 let shift = ogeom_math::Vector2::new(
826 u_period.map_or(0.0, |p| ((prev.x - start.x) / p).round() * p),
827 v_period.map_or(0.0, |p| ((prev.y - start.y) / p).round() * p),
828 );
829 if shift.x != 0.0 || shift.y != 0.0 {
830 pcurve =
831 pcurve.transformed(&ogeom_math::Transform2::translation(shift), tol)?;
832 }
833 }
834 prev_end = Some(pcurve.point_at(t_end, tol)?);
835 }
836 if done.iter().any(|e| e.is_same(edge)) {
837 continue;
838 }
839 done.push(edge.clone());
840 attach_pcurve(
841 model,
842 edge,
843 pcurve,
844 surface_id,
845 edge.location().clone(),
846 prange,
847 )?;
848 }
849 }
850 Ok(built)
851}
852
853pub fn chain_wire_branches(
877 model: &mut Model,
878 surface: ogeom_topo::SurfaceId,
879 wires: &[Shape],
880 tol: Tolerances,
881) -> OgeomResult<()> {
882 use ogeom_geom::Curve2d as _;
883 use ogeom_geom::Surface as _;
884 let Some(geometry) = model.geometry().surface(surface) else {
885 ogeom_bail!(Dangling, "surface is not in this model");
886 };
887 let ((ua, ub), (va, vb)) = geometry.domain();
888 let u_period = geometry.is_periodic_u().then_some(ub - ua);
889 let v_period = geometry.is_periodic_v().then_some(vb - va);
890 if u_period.is_none() && v_period.is_none() {
891 return Ok(());
892 }
893 let whole = |gap: f64, period: Option<f64>| -> f64 {
896 period.map_or(0.0, |p| {
897 let k = (gap / p).round();
898 if k != 0.0 && (gap - k * p).abs() <= p * 0.25 {
899 k * p
900 } else {
901 0.0
902 }
903 })
904 };
905 type Image = (
910 Option<ogeom_topo::PCurveId>,
911 ogeom_math::Point2,
912 ogeom_math::Point2,
913 Vec<ogeom_math::Point2>,
914 );
915 const STEPS: u32 = 8;
917 let images = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Image>> {
918 let mut out = Vec::new();
919 for edge in model.ordered_children_of(wire)? {
920 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
921 ogeom_bail!(Dangling, "edge is not in this model");
922 };
923 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
924 let (id, movable, range) = match data.pcurve_for(surface, edge.location()) {
925 Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, true, *range),
926 Some(EdgeRepr::Seam {
927 forward,
928 reversed: back,
929 range,
930 ..
931 }) => (if reversed { *back } else { *forward }, false, *range),
932 _ => continue,
933 };
934 let Some(planar) = model.geometry().pcurve(id) else {
935 ogeom_bail!(Dangling, "pcurve is not in this model");
936 };
937 let (t_start, t_end) = if reversed {
938 (range.1, range.0)
939 } else {
940 (range.0, range.1)
941 };
942 let mut along = Vec::with_capacity(STEPS as usize - 1);
943 for k in 1..STEPS {
944 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(STEPS);
945 along.push(planar.point_at(t, tol)?);
946 }
947 out.push((
948 movable.then_some(id),
949 planar.point_at(t_start, tol)?,
950 planar.point_at(t_end, tol)?,
951 along,
952 ));
953 }
954 Ok(out)
955 };
956 let mut first_centre: Option<ogeom_math::Point2> = None;
957 for wire in wires {
958 let wire_images = images(model, wire)?;
959 if wire_images.is_empty() {
960 continue;
961 }
962 let mut shifts: Vec<(ogeom_topo::PCurveId, ogeom_math::Vector2)> = Vec::new();
965 let mut prev_end: Option<ogeom_math::Point2> = None;
966 let mut lo = ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY);
967 let mut hi = ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
968 for (id, start, end, along) in &wire_images {
969 let shift = match id {
970 None => ogeom_math::Vector2::new(0.0, 0.0),
971 Some(id) => match shifts.iter().find(|(seen, _)| seen == id) {
972 Some((_, s)) => *s,
973 None => {
974 let s = prev_end.map_or(ogeom_math::Vector2::new(0.0, 0.0), |prev| {
975 ogeom_math::Vector2::new(
976 whole(prev.x - start.x, u_period),
977 whole(prev.y - start.y, v_period),
978 )
979 });
980 shifts.push((*id, s));
981 s
982 }
983 },
984 };
985 let (s, e) = (*start + shift, *end + shift);
986 for p in along.iter().map(|p| *p + shift).chain([s, e]) {
987 lo = ogeom_math::Point2::new(lo.x.min(p.x), lo.y.min(p.y));
988 hi = ogeom_math::Point2::new(hi.x.max(p.x), hi.y.max(p.y));
989 }
990 prev_end = Some(e);
991 }
992 let centre = ogeom_math::Point2::new(f64::midpoint(lo.x, hi.x), f64::midpoint(lo.y, hi.y));
994 let carry = match first_centre {
995 None => {
996 first_centre = Some(centre);
997 ogeom_math::Vector2::new(0.0, 0.0)
998 }
999 Some(first) => ogeom_math::Vector2::new(
1000 u_period.map_or(0.0, |p| ((first.x - centre.x) / p).round() * p),
1001 v_period.map_or(0.0, |p| ((first.y - centre.y) / p).round() * p),
1002 ),
1003 };
1004 for (id, shift) in shifts {
1005 let total = shift + carry;
1006 if total.x == 0.0 && total.y == 0.0 {
1007 continue;
1008 }
1009 let Some(planar) = model.geometry().pcurve(id) else {
1010 ogeom_bail!(Dangling, "pcurve is not in this model");
1011 };
1012 let shifted = planar.transformed(&ogeom_math::Transform2::translation(total), tol)?;
1013 if let Some(slot) = model.geometry_mut().pcurve_mut(id) {
1014 *slot = shifted;
1015 }
1016 }
1017 }
1018 Ok(())
1019}
1020
1021pub fn closed_at_poles(
1041 model: &mut Model,
1042 surface: ogeom_topo::SurfaceId,
1043 edges: &[Shape],
1044 tol: Tolerances,
1045) -> OgeomResult<Vec<Shape>> {
1046 use ogeom_geom::Curve2d as _;
1047 use ogeom_geom::Surface as _;
1048 let Some(geometry) = model.geometry().surface(surface).cloned() else {
1049 ogeom_bail!(Dangling, "surface is not in this model");
1050 };
1051 let ((ua, ub), (va, vb)) = geometry.domain();
1052 let span = (ub - ua).abs().max((vb - va).abs());
1053 let mut ends = Vec::with_capacity(edges.len());
1055 for edge in edges {
1056 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1057 ogeom_bail!(Dangling, "edge is not in this model");
1058 };
1059 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
1060 let (id, range) = match data.pcurve_for(surface, edge.location()) {
1061 Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, *range),
1062 Some(EdgeRepr::Seam {
1063 forward,
1064 reversed: back,
1065 range,
1066 ..
1067 }) => (if reversed { *back } else { *forward }, *range),
1068 _ => return Ok(edges.to_vec()),
1069 };
1070 let Some(planar) = model.geometry().pcurve(id) else {
1071 ogeom_bail!(Dangling, "pcurve is not in this model");
1072 };
1073 let (t_start, t_end) = if reversed {
1074 (range.1, range.0)
1075 } else {
1076 (range.0, range.1)
1077 };
1078 ends.push((planar.point_at(t_start, tol)?, planar.point_at(t_end, tol)?));
1079 }
1080 let collapses = |from: Point2, to: Point2| -> bool {
1081 let Ok(at) = geometry.point_at(from.x, from.y, tol) else {
1082 return false;
1083 };
1084 (1..=4).all(|k| {
1085 let s = f64::from(k) / 4.0;
1086 let p = Point2::new(from.x + (to.x - from.x) * s, from.y + (to.y - from.y) * s);
1087 geometry
1088 .point_at(p.x, p.y, tol)
1089 .is_ok_and(|q| q.distance(at) <= tol.confusion())
1090 })
1091 };
1092 let mut runs: Vec<Option<(Shape, Point2, Point2)>> = Vec::with_capacity(edges.len());
1095 for (i, edge) in edges.iter().enumerate() {
1096 let j = (i + 1) % edges.len();
1097 let (from, to) = (ends[i].1, ends[j].0);
1103 let gap = from.distance(to);
1104 let meets_in_space = || match (
1105 geometry.point_at(from.x, from.y, tol),
1106 geometry.point_at(to.x, to.y, tol),
1107 ) {
1108 (Ok(p), Ok(q)) => p.distance(q) <= tol.confusion(),
1109 _ => false,
1110 };
1111 if gap <= span * 1e-6 || (gap <= tol.confusion() && meets_in_space()) {
1112 runs.push(None);
1113 continue;
1114 }
1115 let (Some((_, arriving)), Some((leaving, _))) = (
1116 edge_vertices(model, edge)?,
1117 edge_vertices(model, &edges[j])?,
1118 ) else {
1119 return Ok(edges.to_vec());
1120 };
1121 if arriving.node() != leaving.node() || !collapses(from, to) {
1122 return Ok(edges.to_vec());
1123 }
1124 runs.push(Some((arriving, from, to)));
1125 }
1126 let mut out = Vec::with_capacity(edges.len() + 2);
1127 for (edge, run) in edges.iter().zip(runs) {
1128 out.push(edge.clone());
1129 let Some((at, from, to)) = run else {
1130 continue;
1131 };
1132 let mut data = EdgeData::new();
1133 data.degenerate = true;
1134 let pole = model.add_edge(data, &[at.clone(), at])?;
1135 attach_pcurve(
1136 model,
1137 &pole,
1138 ogeom_geom::Line2d::segment(from, to, tol)?.into(),
1139 surface,
1140 Location::identity(),
1141 (0.0, from.distance(to)),
1142 )?;
1143 out.push(pole);
1144 }
1145 Ok(out)
1146}
1147
1148pub fn belted_wire(
1169 model: &mut Model,
1170 surface: ogeom_topo::SurfaceId,
1171 wires: &[Shape],
1172 tol: Tolerances,
1173) -> OgeomResult<Option<Shape>> {
1174 use ogeom_geom::Curve2d as _;
1175 use ogeom_geom::Surface as _;
1176 let Some(SurfaceGeometry::BSpline(spline)) = model.geometry().surface(surface).cloned() else {
1177 return Ok(None);
1178 };
1179 if !spline.is_closed_u(tol) || spline.is_periodic_u() || spline.is_closed_v(tol) {
1180 return Ok(None);
1181 }
1182 if wires.is_empty() || wires.len() > 2 {
1183 return Ok(None);
1184 }
1185 let ((u0, u1), (v0, v1)) = spline.domain();
1186 let slack = (u1 - u0).abs().max((v1 - v0).abs()) * 1e-6;
1187 struct Belt {
1190 edge: Shape,
1191 vertex: Shape,
1192 row: f64,
1193 from: f64,
1194 to: f64,
1195 }
1196 let mut belts = Vec::with_capacity(2);
1197 for wire in wires {
1198 let edges = model.ordered_children_of(wire)?;
1199 let [edge] = edges.as_slice() else {
1200 return Ok(None);
1201 };
1202 let Some((a, b)) = edge_vertices(model, edge)? else {
1203 return Ok(None);
1204 };
1205 if !a.is_same(&b) {
1206 return Ok(None);
1207 }
1208 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1209 ogeom_bail!(Dangling, "edge is not in this model");
1210 };
1211 if data.degenerate {
1212 return Ok(None);
1213 }
1214 let Some(EdgeRepr::PCurve { curve, range, .. }) = data.pcurve_for(surface, edge.location())
1215 else {
1216 return Ok(None);
1217 };
1218 let Some(planar) = model.geometry().pcurve(*curve) else {
1219 ogeom_bail!(Dangling, "pcurve is not in this model");
1220 };
1221 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
1222 let (s, e) = (
1223 planar.point_at(range.0, tol)?,
1224 planar.point_at(range.1, tol)?,
1225 );
1226 let (s, e) = if reversed { (e, s) } else { (s, e) };
1227 let column = |x: f64| -> Option<f64> {
1228 if (x - u0).abs() <= slack {
1229 Some(u0)
1230 } else if (x - u1).abs() <= slack {
1231 Some(u1)
1232 } else {
1233 None
1234 }
1235 };
1236 let (Some(from), Some(to)) = (column(s.x), column(e.x)) else {
1237 return Ok(None);
1238 };
1239 if from == to || (s.y - e.y).abs() > slack {
1240 return Ok(None);
1241 }
1242 belts.push(Belt {
1243 edge: edge.clone(),
1244 vertex: a,
1245 row: f64::midpoint(s.y, e.y),
1246 from,
1247 to,
1248 });
1249 }
1250 let (end_vertex, end_row) = if let [_, second] = belts.as_slice() {
1253 if second.from != belts[0].to {
1254 return Ok(None);
1255 }
1256 (second.vertex.clone(), second.row)
1257 } else {
1258 let row = if (belts[0].row - v0).abs() <= (belts[0].row - v1).abs() {
1259 v1
1260 } else {
1261 v0
1262 };
1263 let across = spline.iso_v_curve(row, tol)?;
1264 let points: Vec<Point> = (0..=8)
1265 .map(|k| {
1266 let t = u0 + (u1 - u0) * f64::from(k) / 8.0;
1267 ogeom_geom::Curve3d::point_at(&across, t, tol)
1268 })
1269 .collect::<OgeomResult<_>>()?;
1270 if points
1271 .iter()
1272 .any(|p| p.distance(points[0]) > tol.confusion())
1273 {
1274 return Ok(None);
1275 }
1276 (model.add_vertex(VertexData::new(points[0])), row)
1277 };
1278 let first = &belts[0];
1279 let up = first.row < end_row;
1280 let (lo, hi) = if up {
1281 (first.vertex.clone(), end_vertex)
1282 } else {
1283 (end_vertex, first.vertex.clone())
1284 };
1285 let column_curve: Curve = spline.iso_u_curve(first.to, tol)?.into();
1286 let range = (first.row.min(end_row), first.row.max(end_row));
1287 let seam = make_edge_between(model, column_curve, range, &lo, &hi, tol)?.shape;
1288 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1289 Ok(ogeom_geom::Line2d::over(
1290 ogeom_math::Axis2::new(
1291 Point2::new(u, 0.0),
1292 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1293 ),
1294 range.0,
1295 range.1,
1296 )?
1297 .into())
1298 };
1299 let (forward_col, reversed_col) = if up {
1302 (first.to, first.from)
1303 } else {
1304 (first.from, first.to)
1305 };
1306 attach_seam(
1307 model,
1308 &seam,
1309 column(forward_col)?,
1310 column(reversed_col)?,
1311 surface,
1312 Location::identity(),
1313 range,
1314 )?;
1315 let climb = if up { seam.clone() } else { seam.reversed() };
1316 let mut walk = vec![first.edge.clone(), climb.clone()];
1317 if let [_, second] = belts.as_slice() {
1318 walk.push(second.edge.clone());
1319 }
1320 walk.push(climb.reversed());
1321 Ok(Some(make_wire(model, &walk, tol)?.shape))
1322}
1323
1324pub fn make_natural_face(model: &mut Model, surface: SurfaceGeometry) -> OgeomResult<Built> {
1331 let id = model.geometry_mut().add_surface(surface);
1332 let face = model.add_face(FaceData::natural(id, Location::identity()), &[])?;
1333 Ok(Built::from_nothing(face))
1334}
1335
1336pub fn make_compsolid(model: &mut Model, solids: &[Shape]) -> OgeomResult<Built> {
1357 if solids.len() < 2 {
1358 ogeom_bail!(
1359 Construction,
1360 "a compsolid glues at least two solids; one solid is just a solid"
1361 );
1362 }
1363 let mut faces_of: Vec<ogeom_core::FastSet<ogeom_topo::TShapeId>> = Vec::new();
1365 for solid in solids {
1366 if model.kind_of(solid)? != ShapeType::Solid {
1367 ogeom_bail!(Construction, "a compsolid's members are solids");
1368 }
1369 let set = ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))?
1370 .iter()
1371 .map(Shape::node)
1372 .collect();
1373 faces_of.push(set);
1374 }
1375 let mut joined = vec![false; solids.len()];
1377 joined[0] = true;
1378 let mut frontier = vec![0_usize];
1379 while let Some(current) = frontier.pop() {
1380 for (other, other_faces) in faces_of.iter().enumerate() {
1381 if joined[other] {
1382 continue;
1383 }
1384 if !faces_of[current].is_disjoint(other_faces) {
1385 joined[other] = true;
1386 frontier.push(other);
1387 }
1388 }
1389 }
1390 if joined.iter().any(|j| !j) {
1391 ogeom_bail!(
1392 Construction,
1393 "the solids do not glue into one connected whole: at least one shares no face with the rest. Coincident-but-distinct faces are a compound's arrangement, not a compsolid's; sew or fuse first."
1394 );
1395 }
1396
1397 let compsolid = model.add_compsolid(solids)?;
1398 let mut history = History::new();
1399 for solid in solids {
1400 history.generate(solid, compsolid.clone());
1401 }
1402 Ok(Built::new(compsolid, history))
1403}
1404
1405pub fn make_compound(model: &mut Model, shapes: &[Shape]) -> OgeomResult<Built> {
1415 let compound = model.add_compound(shapes)?;
1416 let mut history = History::new();
1417 for shape in shapes {
1418 history.generate(shape, compound.clone());
1419 }
1420 Ok(Built::new(compound, history))
1421}
1422
1423pub fn make_shell(model: &mut Model, faces: &[Shape]) -> OgeomResult<Built> {
1430 let shell = model.add_shell(faces)?;
1431 let mut history = History::new();
1432 for face in faces {
1433 history.generate(face, shell.clone());
1434 }
1435 Ok(Built::new(shell, history))
1436}
1437
1438pub fn make_solid(model: &mut Model, shells: &[Shape]) -> OgeomResult<Built> {
1457 let mut used = Vec::with_capacity(shells.len());
1458 for (i, shell) in shells.iter().enumerate() {
1459 let encloses = encloses_material(model, shell)?;
1460 let inside_out = encloses.is_some_and(|out| if i == 0 { !out } else { out });
1461 used.push(if inside_out {
1462 shell.reversed()
1463 } else {
1464 shell.clone()
1465 });
1466 }
1467 let solid = model.add_solid(&used)?;
1468 let mut history = History::new();
1469 for shell in shells {
1470 history.generate(shell, solid.clone());
1471 }
1472 Ok(Built::new(solid, history))
1473}
1474
1475fn encloses_material(model: &Model, shell: &Shape) -> OgeomResult<Option<bool>> {
1480 const PROBED: usize = 4;
1482 if model.node(shell).map(ogeom_topo::TShape::kind) != Some(ShapeType::Shell)
1483 || !is_shell_closed(model, shell)?
1484 || !crate::check::walked_one_way(model, shell)?.is_empty()
1485 {
1486 return Ok(None);
1487 }
1488 let tol = Tolerances::default();
1498 let mut read = crate::check::Readings::default();
1499 if let Ok(boundary) = crate::check::probe_boundary(model, shell, tol) {
1500 for face in ogeom_topo::explore_unique(model, shell, ShapeType::Face)?
1501 .iter()
1502 .take(PROBED)
1503 {
1504 let wanted = crate::check::AGREEING - read.count();
1505 if let Ok(more) = crate::check::face_readings(model, face, &boundary, wanted, tol) {
1506 read.out += more.out;
1507 read.inward += more.inward;
1508 }
1509 if read.count() >= crate::check::AGREEING {
1510 break;
1511 }
1512 }
1513 }
1514 if read.count() >= crate::check::AGREEING
1515 && let Some(inward) = read.verdict()
1516 {
1517 return Ok(Some(!inward));
1518 }
1519 Ok(enclosed_volume(model, shell)
1520 .map(|v| v > 0.0)
1521 .or_else(|| read.verdict().map(|inward| !inward)))
1522}
1523
1524fn enclosed_volume(model: &Model, shell: &Shape) -> Option<f64> {
1527 ogeom_mesh::triangulate(
1528 model,
1529 shell,
1530 ogeom_mesh::Deflection::default(),
1531 Tolerances::default(),
1532 )
1533 .ok()
1534 .filter(ogeom_topo::Triangulation::is_closed)
1535 .map(|mesh| mesh.volume())
1536}
1537
1538pub fn is_shell_closed(model: &Model, shell: &Shape) -> OgeomResult<bool> {
1549 let mut uses: FastMap<ogeom_topo::TShapeId, usize> = FastMap::default();
1554 let children = |shape: &Shape| -> OgeomResult<&[Shape]> {
1557 match model.node(shape) {
1558 Some(node) => Ok(node.children()),
1559 None => ogeom_bail!(Dangling, "shape refers to a node not in this model"),
1560 }
1561 };
1562 for face in ogeom_topo::explore(model, shell, ogeom_topo::Filter::OfType(ShapeType::Face))? {
1563 for wire in children(&face)? {
1564 for edge in children(wire)? {
1565 *uses.entry(edge.node()).or_default() += 1;
1566 }
1567 }
1568 }
1569 let degenerate = |id: ogeom_topo::TShapeId| {
1575 model
1576 .node_by_id(id)
1577 .and_then(|n| n.data().as_edge())
1578 .is_some_and(|d| d.degenerate)
1579 };
1580 let counted = uses.keys().any(|id| !degenerate(*id));
1581 Ok(counted && uses.iter().all(|(id, n)| n % 2 == 0 || degenerate(*id)))
1582}
1583
1584pub fn covers_whole_surface(model: &Model, face: &Shape) -> OgeomResult<bool> {
1599 use ogeom_topo::Orientation;
1600 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1601 ogeom_bail!(Dangling, "face is not in this model");
1602 };
1603 let wires = model.children_of(face)?;
1604 if wires.is_empty() {
1605 return Ok(false);
1606 }
1607 let mut walks: FastMap<ogeom_topo::TShapeId, (u32, u32)> = FastMap::default();
1609 for wire in &wires {
1610 for edge in model.children_of(wire)? {
1611 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1612 ogeom_bail!(Dangling, "edge is not in this model");
1613 };
1614 if edge_data.degenerate {
1615 continue;
1616 }
1617 if !matches!(
1618 edge_data.pcurve_for(data.surface, edge.location()),
1619 Some(EdgeRepr::Seam { .. })
1620 ) {
1621 return Ok(false);
1622 }
1623 let walked = walks.entry(edge.node()).or_default();
1624 match edge.orientation() {
1625 Orientation::Forward => walked.0 += 1,
1626 Orientation::Reversed => walked.1 += 1,
1627 Orientation::Internal | Orientation::External => return Ok(false),
1628 }
1629 }
1630 }
1631 Ok(walks.values().all(|&w| w == (1, 1)))
1632}
1633
1634pub fn trimmed_where_bare(model: &mut Model, face: &Shape, tol: Tolerances) -> OgeomResult<()> {
1643 trim_bare_edges(model, face, false, tol)
1644}
1645
1646pub fn trimmed_or_fitted_where_bare(
1663 model: &mut Model,
1664 face: &Shape,
1665 tol: Tolerances,
1666) -> OgeomResult<()> {
1667 trim_bare_edges(model, face, true, tol)
1668}
1669
1670fn trim_bare_edges(model: &mut Model, face: &Shape, fit: bool, tol: Tolerances) -> OgeomResult<()> {
1671 let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
1672 ogeom_bail!(Dangling, "face is not in this model");
1673 };
1674 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
1675 ogeom_bail!(Dangling, "face refers to a surface not in this model");
1676 };
1677 let unplace = face.transform(model.datums())?.inverse()?;
1681 for edge in ogeom_topo::explore_unique(model, face, ShapeType::Edge)? {
1682 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge().cloned()) else {
1683 continue;
1684 };
1685 if edge_data
1686 .pcurve_for(data.surface, edge.location())
1687 .is_some()
1688 {
1689 continue;
1690 }
1691 let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
1692 continue;
1693 };
1694 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1695 continue;
1696 };
1697 use ogeom_geom::Transformable as _;
1698 let placed = geometry.transformed(&(unplace * edge.transform(model.datums())?), tol)?;
1699 let exact = ogeom_intersect::exact_pcurve_of(&placed, &surface, tol).filter(|exact| {
1704 !fit || crate::pcurve_fit::lifted_gap(
1705 (&placed, *range),
1706 (exact, *range),
1707 &surface,
1708 true,
1709 tol,
1710 )
1711 .is_ok_and(|gap| gap <= edge_data.tolerance.get().max(tol.confusion()))
1712 });
1713 let pcurve = match exact {
1714 Some(exact) => exact,
1715 None if fit => {
1716 fitted_where_bare(model, &edge, &edge_data, (&placed, *range), &surface, tol)?
1717 }
1718 None => continue,
1719 };
1720 attach_pcurve(
1721 model,
1722 &edge,
1723 pcurve,
1724 data.surface,
1725 edge.location().clone(),
1726 *range,
1727 )?;
1728 }
1729 Ok(())
1730}
1731
1732fn fitted_where_bare(
1736 model: &mut Model,
1737 edge: &Shape,
1738 edge_data: &EdgeData,
1739 (curve, range): (&Curve, (f64, f64)),
1740 surface: &SurfaceGeometry,
1741 tol: Tolerances,
1742) -> OgeomResult<ogeom_geom::PlanarCurve> {
1743 let reach = edge_data.tolerance.get().max(tol.confusion());
1744 let fitted =
1745 match crate::pcurve_fit::fit_projected_pcurve_within(curve, range, surface, reach, tol)? {
1746 crate::pcurve_fit::CappedFit::Fitted((fitted, ..)) => fitted,
1747 crate::pcurve_fit::CappedFit::TooFar(off) => ogeom_bail!(
1748 Construction,
1749 "an edge of the face sits {off:.2e} off its surface, outside the edge's \
1750 tolerance of {reach:.2e}, so it has no trim there"
1751 ),
1752 };
1753 let gap = crate::pcurve_fit::lifted_gap((curve, range), (&fitted, range), surface, true, tol)?;
1756 if gap > tol.confusion() {
1757 let widened = ogeom_core::Tolerance::new(gap + tol.confusion())?;
1760 model.widen(edge, widened)?;
1761 if let Some((a, b)) = edge_vertices(model, edge)? {
1762 model.widen(&a, widened)?;
1763 model.widen(&b, widened)?;
1764 }
1765 }
1766 Ok(fitted)
1767}
1768
1769pub fn attach_pcurve(
1777 model: &mut Model,
1778 edge: &Shape,
1779 pcurve: ogeom_geom::PlanarCurve,
1780 surface: ogeom_topo::SurfaceId,
1781 location: Location,
1782 range: (f64, f64),
1783) -> OgeomResult<()> {
1784 if model.kind_of(edge)? != ShapeType::Edge {
1785 ogeom_bail!(Construction, "pcurves attach to edges");
1786 }
1787 let id = model.geometry_mut().add_pcurve(pcurve);
1788 let Some(node) = model.node_mut(edge) else {
1789 ogeom_bail!(Dangling, "edge is not in this model");
1790 };
1791 let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1792 ogeom_bail!(Construction, "edge node holds no edge data");
1793 };
1794 data.add(EdgeRepr::PCurve {
1795 curve: id,
1796 surface,
1797 location,
1798 range,
1799 });
1800 Ok(())
1801}
1802
1803pub fn attach_seam(
1817 model: &mut Model,
1818 edge: &Shape,
1819 forward: ogeom_geom::PlanarCurve,
1820 reversed: ogeom_geom::PlanarCurve,
1821 surface: ogeom_topo::SurfaceId,
1822 location: Location,
1823 range: (f64, f64),
1824) -> OgeomResult<()> {
1825 if model.kind_of(edge)? != ShapeType::Edge {
1826 ogeom_bail!(Construction, "seams attach to edges");
1827 }
1828 let forward = model.geometry_mut().add_pcurve(forward);
1829 let reversed = model.geometry_mut().add_pcurve(reversed);
1830 let Some(node) = model.node_mut(edge) else {
1831 ogeom_bail!(Dangling, "edge is not in this model");
1832 };
1833 let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1834 ogeom_bail!(Construction, "edge node holds no edge data");
1835 };
1836 data.add(EdgeRepr::Seam {
1837 forward,
1838 reversed,
1839 surface,
1840 location,
1841 range,
1842 });
1843 Ok(())
1844}
1845
1846fn circle_is_parallel_of(
1850 circle: ogeom_math::Circle,
1851 surface: &ogeom_geom::SurfaceGeometry,
1852 axis_z: ogeom_math::Vector,
1853 tol: Tolerances,
1854) -> bool {
1855 if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1856 return false;
1857 }
1858 match surface_axis_origin(surface) {
1859 Some(origin) => {
1860 let off = circle.centre() - origin;
1861 let radial = off - axis_z * off.dot(axis_z);
1862 radial.magnitude() <= tol.confusion() * 10.0
1863 }
1864 None => true,
1865 }
1866}
1867
1868pub fn rings_are_parallels(
1880 model: &Model,
1881 surface: &ogeom_geom::SurfaceGeometry,
1882 rings: &[&Shape],
1883 tol: Tolerances,
1884) -> OgeomResult<bool> {
1885 let Some(axis_z) = surface_iso_axis(surface) else {
1886 return Ok(false);
1887 };
1888 for ring in rings {
1889 let Some(node) = model.node(ring) else {
1890 ogeom_bail!(Dangling, "edge is not in this model");
1891 };
1892 let Some(data) = node.data().as_edge() else {
1893 return Ok(false);
1894 };
1895 if data.degenerate {
1896 continue;
1897 }
1898 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1899 return Ok(false);
1900 };
1901 let Some(ogeom_geom::Curve::Circle(c)) = model.geometry().curve(*curve) else {
1902 return Ok(false);
1903 };
1904 if !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
1905 return Ok(false);
1906 }
1907 }
1908 Ok(true)
1909}
1910
1911fn surface_axis_origin(surface: &ogeom_geom::SurfaceGeometry) -> Option<Point> {
1914 use ogeom_geom::SurfaceGeometry as S;
1915 match surface {
1916 S::Cylinder(c) => Some(c.cylinder().frame().origin()),
1917 S::Cone(c) => Some(c.cone().frame().origin()),
1918 S::Sphere(s) => Some(s.sphere().centre()),
1919 S::Torus(t) => Some(t.torus().frame().origin()),
1920 _ => None,
1921 }
1922}
1923
1924pub fn make_revolution_band(
1951 model: &mut Model,
1952 surface: &ogeom_geom::SurfaceGeometry,
1953 ring_lo: &Shape,
1954 ring_hi: &Shape,
1955 tol: Tolerances,
1956) -> OgeomResult<Shape> {
1957 revolution_band(model, surface, ring_lo, ring_hi, None, tol)
1958}
1959
1960pub fn make_profile_revolution_band(
1976 model: &mut Model,
1977 surface: &ogeom_geom::SurfaceGeometry,
1978 ring_lo: &Shape,
1979 ring_hi: &Shape,
1980 tol: Tolerances,
1981) -> OgeomResult<Shape> {
1982 use ogeom_geom::Surface as _;
1983 let SurfaceGeometry::Revolution(revolved) = surface else {
1984 ogeom_bail!(
1985 Construction,
1986 "a profile's band asked of a surface no profile sweeps"
1987 );
1988 };
1989 let ((ua, ub), _) = surface.domain();
1990 if !surface.is_periodic_u() || ((ub - ua) - core::f64::consts::TAU).abs() > tol.angular() {
1991 ogeom_bail!(
1992 Construction,
1993 "a profile's band asked of less than a whole turn"
1994 );
1995 }
1996 revolution_band(model, surface, ring_lo, ring_hi, Some(revolved), tol)
1997}
1998
1999fn profile_chart_of(
2004 revolved: &ogeom_geom::RevolutionSurface,
2005 p: Point,
2006 tol: Tolerances,
2007) -> Option<ogeom_math::Point2> {
2008 use ogeom_geom::Curve3d as _;
2009 let axis = revolved.axis();
2010 let curve = revolved.curve();
2011 let (lo, hi) = curve.domain();
2012 let place = |q: Point| {
2014 let off = q - axis.location;
2015 let height = off.dot(axis.direction.vector());
2016 let radial = off - axis.direction.vector() * height;
2017 (height, radial)
2018 };
2019 let (height, radial) = place(p);
2020 let mut found = None;
2021 for end in [lo, hi] {
2022 let q = curve.point_at(end, tol).ok()?;
2023 let (h, r) = place(q);
2024 if (h - height).abs() <= tol.confusion()
2025 && (r.magnitude() - radial.magnitude()).abs() <= tol.confusion()
2026 {
2027 let z = axis.direction.vector();
2028 let x = r / r.magnitude();
2029 let y = z.cross(x);
2030 let angle = radial.dot(y).atan2(radial.dot(x));
2031 found = Some(ogeom_math::Point2::new(
2032 angle.rem_euclid(core::f64::consts::TAU),
2033 end,
2034 ));
2035 break;
2036 }
2037 }
2038 found
2039}
2040
2041fn revolution_band(
2044 model: &mut Model,
2045 surface: &ogeom_geom::SurfaceGeometry,
2046 ring_lo: &Shape,
2047 ring_hi: &Shape,
2048 profile: Option<&ogeom_geom::RevolutionSurface>,
2049 tol: Tolerances,
2050) -> OgeomResult<Shape> {
2051 use ogeom_geom::Surface as _;
2052
2053 let ((ua_dom, ub_dom), _) = surface.domain();
2054 let span = ub_dom - ua_dom;
2055 let axis_z = surface_iso_axis(surface)
2056 .or_else(|| profile.map(|r| r.axis().direction.vector()))
2057 .ok_or_else(|| {
2058 ogeom_core::ogeom_err!(Construction, "the surface has no revolution axis")
2059 })?;
2060 let chart_of = |p: Point| -> Option<ogeom_math::Point2> {
2063 match profile {
2064 Some(revolved) => profile_chart_of(revolved, p, tol),
2065 None => analytic_chart_of(surface, p),
2066 }
2067 };
2068
2069 struct Ring {
2071 edge: Shape,
2072 vertex: Shape,
2073 crange: (f64, f64),
2074 winding: f64,
2075 row: f64,
2076 degenerate: bool,
2077 }
2078 let mut rings = Vec::new();
2079 for used in [ring_lo, ring_hi] {
2080 let edge = &if used.orientation() == ogeom_topo::Orientation::Reversed {
2084 used.reversed()
2085 } else {
2086 used.clone()
2087 };
2088 let Some((vertex, other)) = edge_vertices(model, edge)? else {
2089 ogeom_bail!(Construction, "a band ring has no vertex");
2090 };
2091 if !vertex.is_same(&other) {
2092 ogeom_bail!(Construction, "a band ring is not closed");
2093 }
2094 let at = {
2095 let Some(node) = model.node(&vertex) else {
2096 ogeom_bail!(Dangling, "vertex is not in this model");
2097 };
2098 let Some(data) = node.data().as_vertex() else {
2099 ogeom_bail!(Construction, "vertex node holds no vertex data");
2100 };
2101 data.point
2102 };
2103 let degenerate = {
2104 let Some(node) = model.node(edge) else {
2105 ogeom_bail!(Dangling, "edge is not in this model");
2106 };
2107 let Some(data) = node.data().as_edge() else {
2108 ogeom_bail!(Construction, "edge node holds no edge data");
2109 };
2110 data.degenerate
2111 };
2112 if degenerate {
2113 let Some(uv) = chart_of(at) else {
2118 ogeom_bail!(
2119 Construction,
2120 "a degenerate ring's row cannot be found on this surface"
2121 );
2122 };
2123 rings.push(Ring {
2124 edge: edge.clone(),
2125 vertex,
2126 crange: (0.0, span),
2127 winding: 0.0,
2128 row: uv.y,
2129 degenerate: true,
2130 });
2131 continue;
2132 }
2133 let (curve, crange) = {
2134 let Some(node) = model.node(edge) else {
2135 ogeom_bail!(Dangling, "edge is not in this model");
2136 };
2137 let Some(data) = node.data().as_edge() else {
2138 ogeom_bail!(Construction, "edge node holds no edge data");
2139 };
2140 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2141 ogeom_bail!(Construction, "a band ring has no curve");
2142 };
2143 let Some(geometry) = model.geometry().curve(*curve) else {
2144 ogeom_bail!(Dangling, "curve is not in this model");
2145 };
2146 (geometry.clone(), *range)
2147 };
2148 let ogeom_geom::Curve::Circle(c) = &curve else {
2149 ogeom_bail!(Construction, "a band ring is not a circle");
2150 };
2151 let off_axis = profile.is_some_and(|r| {
2160 let off = c.circle().centre() - r.axis().location;
2161 (off - axis_z * off.dot(axis_z)).magnitude() > tol.confusion() * 10.0
2162 });
2163 if off_axis || !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
2164 ogeom_bail!(
2165 Construction,
2166 "a band ring is a circle on the surface but not a parallel \
2167 of it; its axis or centre is off the revolution axis"
2168 );
2169 }
2170 let turned = if c.is_reversed() { -1.0 } else { 1.0 };
2173 let winding = turned * spin(&c.circle().frame()).dot(axis_z).signum();
2174 let row = match chart_of(at) {
2175 Some(uv) => uv.y,
2176 None => {
2177 crate::measure::project_on_surface(surface, at, 32, tol)?
2178 .parameters
2179 .1
2180 }
2181 };
2182 rings.push(Ring {
2183 edge: edge.clone(),
2184 vertex,
2185 crange,
2186 winding,
2187 row,
2188 degenerate: false,
2189 });
2190 }
2191 if rings[0].degenerate && rings[1].degenerate {
2195 ogeom_bail!(Construction, "both band rings are degenerate");
2196 }
2197 if rings[0].degenerate {
2198 rings.swap(0, 1);
2199 }
2200 if rings[1].degenerate {
2201 rings[1].winding = -rings[0].winding;
2202 }
2203 if surface.is_periodic_v() {
2208 let (_, (va, vb)) = surface.domain();
2209 let on_seam =
2210 |row: f64| (row - va).abs() <= tol.angular() || (vb - row).abs() <= tol.angular();
2211 for (this, other) in [(0, 1), (1, 0)] {
2212 if on_seam(rings[this].row) && !on_seam(rings[other].row) {
2213 let toward = rings[other].row;
2214 rings[this].row = if (toward - va).abs() <= (vb - toward).abs() {
2215 va
2216 } else {
2217 vb
2218 };
2219 }
2220 }
2221 }
2222 let anchor = {
2223 let Some(node) = model.node(&rings[0].vertex) else {
2224 ogeom_bail!(Dangling, "vertex is not in this model");
2225 };
2226 let Some(data) = node.data().as_vertex() else {
2227 ogeom_bail!(Construction, "vertex node holds no vertex data");
2228 };
2229 data.point
2230 };
2231 let ua = match chart_of(anchor) {
2232 Some(uv) => uv.x,
2233 None => {
2234 crate::measure::project_on_surface(surface, anchor, 32, tol)?
2235 .parameters
2236 .0
2237 }
2238 };
2239 if !rings[1].degenerate && circle_crossing_of(model, &rings[1].edge, anchor, tol)?.is_some() {
2240 ogeom_bail!(
2241 Construction,
2242 "the band rings' vertices stand on different columns, which no \
2243 seam joins; split_ring_on_column gives one rim a vertex on the \
2244 other's column"
2245 );
2246 }
2247
2248 let (va, vb) = (rings[0].row, rings[1].row);
2251 let turned = match profile {
2252 Some(revolved) => {
2253 use ogeom_geom::Transformable as _;
2254 let turn = ogeom_math::Transform::rotation(revolved.axis(), ua);
2255 Some(revolved.curve().clone().transformed(&turn, tol)?)
2256 }
2257 None => seam_iso_u_curve(surface, ua, tol),
2258 };
2259 let Some(seam_curve) = turned else {
2260 ogeom_bail!(
2261 Construction,
2262 "the surface's iso-curve has no closed form; no seam can be built"
2263 );
2264 };
2265 let (range, from, to, downward) = if va <= vb {
2266 (
2267 (va, vb),
2268 rings[0].vertex.clone(),
2269 rings[1].vertex.clone(),
2270 false,
2271 )
2272 } else {
2273 (
2274 (vb, va),
2275 rings[1].vertex.clone(),
2276 rings[0].vertex.clone(),
2277 true,
2278 )
2279 };
2280 let curve_range = (
2283 iso_curve_parameter_at(surface, range.0),
2284 iso_curve_parameter_at(surface, range.1),
2285 );
2286 let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
2287
2288 let surface_id = model.geometry_mut().add_surface(surface.clone());
2292
2293 for ring in &rings {
2296 let u_start = if ring.winding > 0.0 { ua } else { ua + span };
2297 let origin =
2298 ogeom_math::Point2::new(ring.winding.mul_add(-ring.crange.0, u_start), ring.row);
2299 let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2300 ogeom_math::Axis2::new(
2301 origin,
2302 ogeom_math::Direction2::new(ogeom_math::Vector2::new(ring.winding, 0.0), tol)?,
2303 ),
2304 ring.crange.0,
2305 ring.crange.1,
2306 )?
2307 .into();
2308 attach_pcurve(
2309 model,
2310 &ring.edge,
2311 pcurve,
2312 surface_id,
2313 Location::identity(),
2314 ring.crange,
2315 )?;
2316 }
2317
2318 let bottom_end = if rings[0].winding > 0.0 {
2321 ua + span
2322 } else {
2323 ua
2324 };
2325 let other_col = if rings[0].winding > 0.0 {
2326 ua
2327 } else {
2328 ua + span
2329 };
2330 let hi_reversed = (rings[1].winding - rings[0].winding).abs() < 0.5;
2331 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2332 Ok(ogeom_geom::Line2d::over(
2333 ogeom_math::Axis2::new(
2334 ogeom_math::Point2::new(u, 0.0),
2335 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
2336 ),
2337 range.0 - 1.0,
2338 range.1 + 1.0,
2339 )?
2340 .into())
2341 };
2342 let counter_clockwise = (rings[0].winding > 0.0) == (va < vb);
2347 let (forward_col, reversed_col) = if downward == counter_clockwise {
2352 (other_col, bottom_end)
2353 } else {
2354 (bottom_end, other_col)
2355 };
2356 attach_seam(
2357 model,
2358 &seam,
2359 column(forward_col)?,
2360 column(reversed_col)?,
2361 surface_id,
2362 Location::identity(),
2363 range,
2364 )?;
2365
2366 let up = if downward {
2367 seam.reversed()
2368 } else {
2369 seam.clone()
2370 };
2371 let top = if hi_reversed {
2372 rings[1].edge.reversed()
2373 } else {
2374 rings[1].edge.clone()
2375 };
2376 let ring = if counter_clockwise {
2377 vec![rings[0].edge.clone(), up.clone(), top, up.reversed()]
2378 } else {
2379 vec![
2380 rings[0].edge.reversed(),
2381 up.clone(),
2382 top.reversed(),
2383 up.reversed(),
2384 ]
2385 };
2386 let wire = make_wire(model, &ring, tol)?.shape;
2387 Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
2388}
2389
2390type RingCrossing = (Curve, (f64, f64), f64, Point);
2393
2394fn circle_crossing_of(
2398 model: &Model,
2399 ring: &Shape,
2400 anchor: Point,
2401 tol: Tolerances,
2402) -> OgeomResult<Option<RingCrossing>> {
2403 let Some(data) = model.node(ring).and_then(|n| n.data().as_edge()) else {
2404 ogeom_bail!(Dangling, "edge is not in this model");
2405 };
2406 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2407 ogeom_bail!(Construction, "a band ring has no curve");
2408 };
2409 let range = *range;
2410 let Some(geometry) = model.geometry().curve(*curve) else {
2411 ogeom_bail!(Dangling, "curve is not in this model");
2412 };
2413 let Curve::Circle(c) = geometry else {
2414 ogeom_bail!(Construction, "a band ring is not a circle");
2415 };
2416 let circle = c.circle();
2417 let local = circle.frame().to_local(anchor);
2418 if local.x.hypot(local.y) <= tol.confusion() {
2419 ogeom_bail!(
2420 Construction,
2421 "the anchor stands on the ring's axis, where every column meets"
2422 );
2423 }
2424 let angle = local.y.atan2(local.x);
2425 let along = if c.is_reversed() { -angle } else { angle };
2426 let period = core::f64::consts::TAU;
2427 let t = range.0 + (along - range.0).rem_euclid(period);
2428 let point = geometry.point_at(t, tol)?;
2429 let start = geometry.point_at(range.0, tol)?;
2430 let end = geometry.point_at(range.1, tol)?;
2431 if point.distance(start) <= tol.confusion() || point.distance(end) <= tol.confusion() {
2432 return Ok(None);
2433 }
2434 Ok(Some((geometry.clone(), range, t, point)))
2435}
2436
2437pub fn split_ring_on_column(
2462 model: &mut Model,
2463 surface: &SurfaceGeometry,
2464 ring: &Shape,
2465 anchor: &Shape,
2466 tol: Tolerances,
2467) -> OgeomResult<Option<Built>> {
2468 let rim = ring.oriented(ogeom_topo::Orientation::Forward);
2469 let Some((vertex, other)) = edge_vertices(model, &rim)? else {
2470 ogeom_bail!(Construction, "a band ring has no vertex");
2471 };
2472 if !vertex.is_same(&other) {
2473 ogeom_bail!(Construction, "a band ring is not closed");
2474 }
2475 if !rings_are_parallels(model, surface, &[&rim], tol)? {
2476 ogeom_bail!(
2477 Construction,
2478 "a band ring is not a parallel of the surface it bounds"
2479 );
2480 }
2481 let anchor_point = {
2482 let Some(data) = model.node(anchor).and_then(|n| n.data().as_vertex()) else {
2483 ogeom_bail!(Construction, "the anchor is not a vertex of this model");
2484 };
2485 anchor.transform(model.datums())?.apply(data.point)
2486 };
2487 let Some((curve, range, t, point)) = circle_crossing_of(model, &rim, anchor_point, tol)? else {
2488 return Ok(None);
2489 };
2490 let stated = model
2491 .node(&rim)
2492 .and_then(|n| n.data().as_edge())
2493 .map(|d| d.tolerance);
2494 let column = make_vertex(model, point).shape;
2495 let head = make_edge_between(model, curve.clone(), (range.0, t), &vertex, &column, tol)?.shape;
2496 let tail = make_edge_between(model, curve, (t, range.1), &column, &vertex, tol)?.shape;
2497 if let Some(stated) = stated {
2498 model.widen(&head, stated)?;
2499 model.widen(&tail, stated)?;
2500 }
2501 let wire = make_wire(model, &[tail.clone(), head.clone()], tol)?.shape;
2502 let mut history = History::new();
2503 history.modify(&rim, head);
2504 history.modify(&rim, tail);
2505 Ok(Some(Built::new(wire, history)))
2506}
2507
2508pub fn make_band_of_rings(
2528 model: &mut Model,
2529 surface: &SurfaceGeometry,
2530 ring_a: &[Shape],
2531 ring_b: &[Shape],
2532 tol: Tolerances,
2533) -> OgeomResult<Shape> {
2534 use ogeom_geom::Curve2d as _;
2535 use ogeom_geom::Surface as _;
2536 use ogeom_math::{Axis2, Direction2, Point2, Transform2, Vector2};
2537
2538 let ((ua_dom, ub_dom), _) = surface.domain();
2539 let span = ub_dom - ua_dom;
2540 if !surface.is_periodic_u() || span <= 0.0 || ring_a.is_empty() || ring_b.is_empty() {
2541 ogeom_bail!(
2542 Construction,
2543 "a band needs two rings on a surface closing in u"
2544 );
2545 }
2546 let point_of = |model: &Model, vertex: &Shape| -> OgeomResult<Point> {
2547 let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
2548 ogeom_bail!(Dangling, "vertex is not in this model");
2549 };
2550 Ok(vertex.transform(model.datums())?.apply(data.point))
2551 };
2552 let start_of = |model: &Model, ring: &[Shape]| -> OgeomResult<Shape> {
2553 let Some((start, _)) = edge_vertices(model, &ring[0])? else {
2554 ogeom_bail!(Construction, "a band ring's edge has no vertex");
2555 };
2556 Ok(start)
2557 };
2558 let (start_a, start_b) = (start_of(model, ring_a)?, start_of(model, ring_b)?);
2559 let surface = {
2563 let Some(at) = analytic_chart_of(surface, point_of(model, &start_a)?) else {
2564 ogeom_bail!(Construction, "the band's surface has no closed-form chart");
2565 };
2566 let (Some(origin), Some(axis)) = (surface_axis_origin(surface), surface_iso_axis(surface))
2567 else {
2568 ogeom_bail!(Construction, "the band's surface has no axis");
2569 };
2570 let turn = ogeom_math::Transform::rotation(
2571 ogeom_math::Axis::new(origin, ogeom_math::Direction::new(axis, tol)?),
2572 at.x - ua_dom,
2573 );
2574 ogeom_geom::Transformable::transformed(surface, &turn, tol)?
2575 };
2576 let surface = &surface;
2577 let (Some(chart_a), Some(chart_b)) = (
2578 analytic_chart_of(surface, point_of(model, &start_a)?),
2579 analytic_chart_of(surface, point_of(model, &start_b)?),
2580 ) else {
2581 ogeom_bail!(Construction, "the band's surface has no closed-form chart");
2582 };
2583 let column = if (chart_a.x - ub_dom).abs() <= 1e-9 * span {
2585 ua_dom
2586 } else {
2587 chart_a.x
2588 };
2589 let degenerate_b = ring_b.len() == 1
2590 && model
2591 .node(&ring_b[0])
2592 .and_then(|n| n.data().as_edge())
2593 .is_some_and(|d| d.degenerate);
2594 let apart = (chart_b.x - column).rem_euclid(span);
2596 if !degenerate_b && apart.min(span - apart) > 1e-6 * span {
2597 ogeom_bail!(
2598 Construction,
2599 "the band rings' start vertices are not on one column"
2600 );
2601 }
2602
2603 type Walk = (Vec<(Shape, ogeom_geom::PlanarCurve, (f64, f64))>, Point2);
2606 let walk = |model: &Model, ring: &[Shape], from: Point2| -> OgeomResult<Walk> {
2607 let mut out = Vec::with_capacity(ring.len());
2608 let mut at = from;
2609 for occurrence in ring {
2610 let Some(data) = model.node(occurrence).and_then(|n| n.data().as_edge()) else {
2611 ogeom_bail!(Dangling, "edge is not in this model");
2612 };
2613 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2614 ogeom_bail!(Construction, "a band ring's edge has no curve");
2615 };
2616 let Some(geometry) = model.geometry().curve(*curve) else {
2617 ogeom_bail!(Dangling, "curve is not in this model");
2618 };
2619 let geometry = ogeom_geom::Transformable::transformed(
2620 geometry,
2621 &occurrence.transform(model.datums())?,
2622 tol,
2623 )?;
2624 let Some(pcurve) = ogeom_intersect::exact_pcurve_over(&geometry, *range, surface, tol)
2625 else {
2626 ogeom_bail!(Construction, "a band ring's edge has no closed-form pcurve");
2627 };
2628 let pcurve = match &pcurve {
2631 ogeom_geom::PlanarCurve::Line(l) => {
2632 let (lo, hi) = (l.domain().0.min(range.0), l.domain().1.max(range.1));
2633 ogeom_geom::Line2d::over(l.axis(), lo, hi)?.into()
2634 }
2635 _ => pcurve,
2636 };
2637 let reversed = occurrence.orientation() == ogeom_topo::Orientation::Reversed;
2638 let (t0, t1) = if reversed {
2639 (range.1, range.0)
2640 } else {
2641 (range.0, range.1)
2642 };
2643 let start = pcurve.point_at(t0, tol)?;
2644 let turns = ((at.x - start.x) / span).round();
2645 let pcurve = pcurve.transformed(
2646 &Transform2::translation(Vector2::new(turns * span, 0.0)),
2647 tol,
2648 )?;
2649 at = pcurve.point_at(t1, tol)?;
2650 out.push((occurrence.clone(), pcurve, *range));
2651 }
2652 Ok((out, at))
2653 };
2654 let once_round = |from: f64, to: f64| ((to - from).abs() - span).abs() <= 1e-6 * span;
2655
2656 let (mut walked_a, mut end_a) = walk(model, ring_a, Point2::new(column, chart_a.y))?;
2659 let column = if end_a.x < column {
2660 (walked_a, end_a) = walk(model, ring_a, Point2::new(column + span, chart_a.y))?;
2661 column + span
2662 } else {
2663 column
2664 };
2665 if !once_round(column, end_a.x) {
2666 ogeom_bail!(Construction, "the first band ring does not go once round");
2667 }
2668 let end_column = end_a.x;
2669
2670 let mut ring_b: Vec<Shape> = ring_b.to_vec();
2672 let walked_b = if degenerate_b {
2673 let toward = (column - end_column).signum();
2674 let pole: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2675 Axis2::new(
2676 Point2::new(end_column, chart_b.y),
2677 Direction2::new(Vector2::new(toward, 0.0), tol)?,
2678 ),
2679 0.0,
2680 span,
2681 )?
2682 .into();
2683 vec![(ring_b[0].clone(), pole, (0.0, span))]
2684 } else {
2685 let mut walked = walk(model, &ring_b, Point2::new(end_column, chart_b.y))?;
2686 if !once_round(end_column, walked.1.x) {
2687 ogeom_bail!(Construction, "the second band ring does not go once round");
2688 }
2689 if (walked.1.x - column).abs() > 1e-6 * span {
2690 ring_b = ring_b.iter().rev().map(Shape::reversed).collect();
2691 walked = walk(model, &ring_b, Point2::new(end_column, chart_b.y))?;
2692 }
2693 walked.0
2694 };
2695
2696 let surface_id = model.geometry_mut().add_surface(surface.clone());
2697 for (edge, pcurve, range) in walked_a.into_iter().chain(walked_b) {
2698 attach_pcurve(
2699 model,
2700 &edge,
2701 pcurve,
2702 surface_id,
2703 Location::identity(),
2704 range,
2705 )?;
2706 }
2707
2708 let (va, vb) = (chart_a.y, chart_b.y);
2712 let Some(seam_curve) = seam_iso_u_curve(surface, column, tol) else {
2713 ogeom_bail!(
2714 Construction,
2715 "the surface's iso-curve has no closed form; no seam can be built"
2716 );
2717 };
2718 let (rows, from, to, downward) = if va <= vb {
2719 ((va, vb), start_a.clone(), start_b.clone(), false)
2720 } else {
2721 ((vb, va), start_b.clone(), start_a.clone(), true)
2722 };
2723 let curve_range = (
2724 iso_curve_parameter_at(surface, rows.0),
2725 iso_curve_parameter_at(surface, rows.1),
2726 );
2727 let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
2728 let side = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2729 Ok(ogeom_geom::Line2d::over(
2730 Axis2::new(
2731 Point2::new(u, 0.0),
2732 Direction2::new(Vector2::new(0.0, 1.0), tol)?,
2733 ),
2734 rows.0 - 1.0,
2735 rows.1 + 1.0,
2736 )?
2737 .into())
2738 };
2739 let clockwise = (end_column - column) * (vb - va) < 0.0;
2746 let (forward_side, reversed_side) = if downward != clockwise {
2747 (column, end_column)
2748 } else {
2749 (end_column, column)
2750 };
2751 attach_seam(
2752 model,
2753 &seam,
2754 side(forward_side)?,
2755 side(reversed_side)?,
2756 surface_id,
2757 Location::identity(),
2758 rows,
2759 )?;
2760 let up = if downward {
2761 seam.reversed()
2762 } else {
2763 seam.clone()
2764 };
2765 let mut edges: Vec<Shape> = ring_a.to_vec();
2766 edges.push(up.clone());
2767 edges.extend(ring_b);
2768 edges.push(up.reversed());
2769 if clockwise {
2770 edges = edges.iter().rev().map(Shape::reversed).collect();
2771 }
2772 let wire = make_wire(model, &edges, tol)?.shape;
2773 let face = make_face_on(model, surface_id, &[wire], tol)?.shape;
2774 Ok(if clockwise { face.reversed() } else { face })
2775}
2776
2777pub fn make_band_between(
2800 model: &mut Model,
2801 surface: &SurfaceGeometry,
2802 rings: [(&Shape, ogeom_geom::PlanarCurve); 2],
2803 tol: Tolerances,
2804) -> OgeomResult<Shape> {
2805 use ogeom_geom::Curve2d as _;
2806 use ogeom_geom::Surface as _;
2807
2808 let surface_id = model.geometry_mut().add_surface(surface.clone());
2809 let ((ua_dom, ub_dom), _) = surface.domain();
2810 let span = ub_dom - ua_dom;
2811
2812 struct Ring {
2813 edge: Shape,
2814 vertex: Shape,
2815 crange: (f64, f64),
2816 pcurve: ogeom_geom::PlanarCurve,
2817 start: ogeom_math::Point2,
2818 }
2819 let mut prepared: Vec<Ring> = Vec::with_capacity(2);
2820 for (used, pcurve) in rings {
2821 let edge = if used.orientation() == ogeom_topo::Orientation::Reversed {
2822 used.reversed()
2823 } else {
2824 used.clone()
2825 };
2826 let Some((vertex, other)) = edge_vertices(model, &edge)? else {
2827 ogeom_bail!(Construction, "a band ring has no vertex");
2828 };
2829 if !vertex.is_same(&other) {
2830 ogeom_bail!(Construction, "a band ring is not closed");
2831 }
2832 let crange = {
2833 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2834 ogeom_bail!(Construction, "a band ring holds no edge data");
2835 };
2836 if data.degenerate {
2839 (0.0, span)
2840 } else {
2841 let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
2842 ogeom_bail!(Construction, "a band ring has no curve");
2843 };
2844 *range
2845 }
2846 };
2847 let start = pcurve.point_at(crange.0, tol)?;
2848 let end = pcurve.point_at(crange.1, tol)?;
2849 if (end.x - start.x - span).abs() > 1e-3 {
2850 ogeom_bail!(
2851 Construction,
2852 "a band ring's chart image must run the period forward in u"
2853 );
2854 }
2855 prepared.push(Ring {
2856 edge,
2857 vertex,
2858 crange,
2859 pcurve,
2860 start,
2861 });
2862 }
2863 if prepared[1].start.y < prepared[0].start.y {
2867 prepared.swap(0, 1);
2868 }
2869 let turns = ((prepared[1].start.x - prepared[0].start.x) / span).round();
2877 if turns != 0.0 {
2878 let shift =
2879 ogeom_math::Transform2::translation(ogeom_math::Vector2::new(-turns * span, 0.0));
2880 prepared[1].pcurve = prepared[1].pcurve.transformed(&shift, tol)?;
2881 prepared[1].start = shift.apply(prepared[1].start);
2882 }
2883 let (start0, start1) = (prepared[0].start, prepared[1].start);
2884 let dcol = start1.x - start0.x;
2885 let stretch = {
2891 let (du, _) = surface.d1_at(start0.x, start0.y, tol)?;
2892 du.magnitude().max(1.0)
2893 };
2894 let (seam, up_is_forward, chart_from, chart_to) = if dcol.abs() * stretch <= tol.confusion() {
2895 let column = start0.x;
2896 let (va, vb) = (start0.y, start1.y);
2897 let Some(seam_curve) = seam_iso_u_curve(surface, column, tol) else {
2898 ogeom_bail!(
2899 Construction,
2900 "the surface's iso-curve has no closed form; no seam can be built"
2901 );
2902 };
2903 let (range, from, to, downward) = if va <= vb {
2904 (
2905 (va, vb),
2906 prepared[0].vertex.clone(),
2907 prepared[1].vertex.clone(),
2908 false,
2909 )
2910 } else {
2911 (
2912 (vb, va),
2913 prepared[1].vertex.clone(),
2914 prepared[0].vertex.clone(),
2915 true,
2916 )
2917 };
2918 let curve_range = (
2919 iso_curve_parameter_at(surface, range.0),
2920 iso_curve_parameter_at(surface, range.1),
2921 );
2922 for (vertex, at) in [(&from, curve_range.0), (&to, curve_range.1)] {
2925 let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
2926 ogeom_bail!(Construction, "a band ring's start vertex holds no point");
2927 };
2928 let placed = vertex.transform(model.datums())?.apply(data.point);
2929 let miss = seam_curve.point_at(at, tol)?.distance(placed);
2930 if miss > tol.confusion() {
2931 model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
2932 }
2933 }
2934 let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
2935 let (a, b) = (
2936 ogeom_math::Point2::new(column, range.0),
2937 ogeom_math::Point2::new(column, range.1),
2938 );
2939 (seam, !downward, a, b)
2940 } else if !matches!(surface, SurfaceGeometry::Cylinder(_)) {
2941 let (a, b, from, to, forward) = if dcol > 0.0 {
2947 (
2948 start0,
2949 start1,
2950 prepared[0].vertex.clone(),
2951 prepared[1].vertex.clone(),
2952 true,
2953 )
2954 } else {
2955 (
2956 start1,
2957 start0,
2958 prepared[1].vertex.clone(),
2959 prepared[0].vertex.clone(),
2960 false,
2961 )
2962 };
2963 let length = (b.x - a.x).hypot(b.y - a.y);
2964 const SAMPLES: usize = 64;
2965 let target = tol.confusion() * 10.0;
2968 let stations = crate::traced::stations_between((0.0, length), &[], 3, SAMPLES);
2969 let fitted = crate::traced::fit_traced(
2970 |t| {
2971 let f = t / length;
2972 surface.point_at(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f, tol)
2973 },
2974 &stations,
2975 3,
2976 target,
2977 tol,
2978 )?;
2979 if fitted.error > tol.confusion() * 1e3 {
2980 ogeom_bail!(
2981 NotDone,
2982 "a band's connector reached {} against a target of {target}",
2983 fitted.error
2984 );
2985 }
2986 let fit_error = fitted.error;
2987 let connector: ogeom_geom::Curve = ogeom_geom::Curve::BSpline(fitted.curve);
2988 for (vertex, at) in [(&from, 0.0), (&to, length)] {
2993 let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
2994 ogeom_bail!(Construction, "a band ring's start vertex holds no point");
2995 };
2996 let placed = vertex.transform(model.datums())?.apply(data.point);
2997 let miss = connector.point_at(at, tol)?.distance(placed);
2998 if miss > tol.confusion() {
2999 model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
3000 }
3001 }
3002 let seam = make_edge_between(model, connector, (0.0, length), &from, &to, tol)?.shape;
3003 if fit_error > tol.confusion() {
3004 model.widen(
3005 &seam,
3006 ogeom_core::Tolerance::new(fit_error + tol.confusion())?,
3007 )?;
3008 }
3009 (seam, forward, a, b)
3010 } else {
3011 let SurfaceGeometry::Cylinder(c) = surface else {
3012 ogeom_bail!(
3013 Construction,
3014 "a band whose rings start on different columns needs a chart \
3015 connector; the surface above was not a cylinder"
3016 );
3017 };
3018 let cylinder = c.cylinder();
3019 let frame = cylinder.frame();
3020 let (a, b, from, to, forward) = if dcol > 0.0 {
3023 (
3024 start0,
3025 start1,
3026 prepared[0].vertex.clone(),
3027 prepared[1].vertex.clone(),
3028 true,
3029 )
3030 } else {
3031 (
3032 start1,
3033 start0,
3034 prepared[1].vertex.clone(),
3035 prepared[0].vertex.clone(),
3036 false,
3037 )
3038 };
3039 let drow = b.y - a.y;
3040 let connector: ogeom_geom::Curve = if drow.abs() <= 1e-9 {
3041 let lifted = ogeom_math::Frame::new(
3043 frame.origin() + frame.z().vector() * a.y,
3044 frame.z(),
3045 frame.x(),
3046 tol,
3047 )?;
3048 ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(lifted, cylinder.radius(), tol)?)
3049 .into()
3050 } else {
3051 let pitch = core::f64::consts::TAU * drow / (b.x - a.x);
3054 let dropped = ogeom_math::Frame::new(
3055 frame.origin()
3056 + frame.z().vector() * (pitch / core::f64::consts::TAU).mul_add(-a.x, a.y),
3057 frame.z(),
3058 frame.x(),
3059 tol,
3060 )?;
3061 ogeom_geom::HelixCurve::over(dropped, cylinder.radius(), pitch, a.x, b.x)?.into()
3062 };
3063 let seam = make_edge_between(model, connector, (a.x, b.x), &from, &to, tol)?.shape;
3064 (seam, forward, a, b)
3065 };
3066
3067 let segment_line = |shift: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
3072 let a = ogeom_math::Point2::new(chart_from.x + shift, chart_from.y);
3073 let b = ogeom_math::Point2::new(chart_to.x + shift, chart_to.y);
3074 let d = ogeom_math::Vector2::new(b.x - a.x, b.y - a.y);
3075 let m = d.x.hypot(d.y);
3076 if m <= 1e-12 {
3077 ogeom_bail!(Construction, "the seam's chart image has no length");
3078 }
3079 Ok(ogeom_geom::Line2d::over(
3080 ogeom_math::Axis2::new(a, ogeom_math::Direction2::new(d, tol)?),
3081 0.0,
3082 m,
3083 )?
3084 .into())
3085 };
3086 let (forward_shift, reversed_shift) = if up_is_forward {
3087 (span, 0.0)
3088 } else {
3089 (0.0, span)
3090 };
3091 let seam_length = (chart_to.x - chart_from.x).hypot(chart_to.y - chart_from.y);
3094 attach_seam(
3095 model,
3096 &seam,
3097 segment_line(forward_shift)?,
3098 segment_line(reversed_shift)?,
3099 surface_id,
3100 Location::identity(),
3101 (0.0, seam_length),
3102 )?;
3103 crate::state_pcurve_gaps_of(model, core::slice::from_ref(&seam), tol)?;
3104 for ring in &prepared {
3105 attach_pcurve(
3106 model,
3107 &ring.edge,
3108 ring.pcurve.clone(),
3109 surface_id,
3110 Location::identity(),
3111 ring.crange,
3112 )?;
3113 }
3114
3115 let up = if up_is_forward {
3116 seam.clone()
3117 } else {
3118 seam.reversed()
3119 };
3120 let walk = vec![
3121 prepared[0].edge.clone(),
3122 up.clone(),
3123 prepared[1].edge.reversed(),
3124 up.reversed(),
3125 ];
3126 let wire = make_wire(model, &walk, tol)?.shape;
3127 Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
3128}
3129
3130pub fn make_apex_band(
3144 model: &mut Model,
3145 surface: &SurfaceGeometry,
3146 ring: &Shape,
3147 tol: Tolerances,
3148) -> OgeomResult<Shape> {
3149 let SurfaceGeometry::Cone(c) = surface else {
3150 ogeom_bail!(
3151 Construction,
3152 "only a cone has the single apex a one-ring cap implies"
3153 );
3154 };
3155 let apex = model.add_vertex(VertexData::new(c.cone().apex()));
3156 let mut data = EdgeData::new();
3157 data.degenerate = true;
3158 let edge = model.add_edge(data, &[apex.clone(), apex])?;
3159 make_revolution_band(model, surface, ring, &edge, tol)
3160}
3161
3162fn analytic_chart_of(
3171 surface: &SurfaceGeometry,
3172 p: ogeom_math::Point,
3173) -> Option<ogeom_math::Point2> {
3174 use ogeom_geom::Surface as _;
3175 let tau = core::f64::consts::TAU;
3176 let raw = match surface {
3177 SurfaceGeometry::Cylinder(s) => {
3178 let l = s.cylinder().frame().to_local(p);
3179 ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
3180 }
3181 SurfaceGeometry::Cone(s) => {
3182 let l = s.cone().frame().to_local(p);
3183 ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
3184 }
3185 SurfaceGeometry::Sphere(s) => {
3186 let sphere = s.sphere();
3187 let l = sphere.frame().to_local(p);
3188 let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
3189 ogeom_math::Point2::new(l.y.atan2(l.x), lat)
3190 }
3191 SurfaceGeometry::Torus(s) => {
3192 let torus = s.torus();
3193 let l = torus.frame().to_local(p);
3194 let radial = l.x.hypot(l.y) - torus.major_radius();
3195 ogeom_math::Point2::new(l.y.atan2(l.x), l.z.atan2(radial))
3196 }
3197 _ => return None,
3198 };
3199 let ((ua, _), (va, vb)) = surface.domain();
3200 let u = ua + (raw.x - ua).rem_euclid(tau);
3201 let v = if surface.is_periodic_v() {
3202 va + (raw.y - va).rem_euclid(vb - va)
3203 } else {
3204 raw.y
3205 };
3206 Some(ogeom_math::Point2::new(u, v))
3207}
3208
3209fn surface_iso_axis(surface: &ogeom_geom::SurfaceGeometry) -> Option<ogeom_math::Vector> {
3212 match surface {
3213 ogeom_geom::SurfaceGeometry::Cylinder(c) => Some(spin(&c.cylinder().frame())),
3214 ogeom_geom::SurfaceGeometry::Cone(c) => Some(spin(&c.cone().frame())),
3215 ogeom_geom::SurfaceGeometry::Torus(t) => Some(spin(&t.torus().frame())),
3216 ogeom_geom::SurfaceGeometry::Sphere(s) => Some(spin(&s.sphere().frame())),
3217 _ => None,
3218 }
3219}
3220
3221fn spin(frame: &ogeom_math::Frame) -> ogeom_math::Vector {
3224 frame.z().vector() * frame.handedness().sign()
3225}
3226
3227pub fn surface_iso_u_curve(
3234 surface: &SurfaceGeometry,
3235 at: f64,
3236 tol: Tolerances,
3237) -> Option<ogeom_geom::Curve> {
3238 match surface {
3239 ogeom_geom::SurfaceGeometry::Cylinder(c) => {
3240 let cylinder = c.cylinder();
3241 let frame = cylinder.frame();
3242 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3243 let location = frame.origin() + radial * cylinder.radius();
3244 let axis = ogeom_math::Axis {
3245 location,
3246 direction: frame.z(),
3247 };
3248 Some(ogeom_geom::LineCurve::new(axis).into())
3249 }
3250 ogeom_geom::SurfaceGeometry::Torus(t) => {
3251 let torus = t.torus();
3252 let frame = torus.frame();
3253 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3254 let centre = frame.origin() + radial * torus.major_radius();
3255 let circle_frame = ogeom_math::Frame::new(
3259 centre,
3260 ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
3261 ogeom_math::Direction::new(radial, tol).ok()?,
3262 tol,
3263 )
3264 .ok()?;
3265 let circle = ogeom_math::Circle::new(circle_frame, torus.minor_radius(), tol).ok()?;
3266 Some(ogeom_geom::CircleCurve::new(circle).into())
3267 }
3268 ogeom_geom::SurfaceGeometry::Cone(c) => {
3269 let cone = c.cone();
3270 let frame = cone.frame();
3271 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3272 let location = frame.origin() + radial * cone.radius_at(0.0);
3276 let direction = ogeom_math::Direction::new(
3277 frame.z().vector() + radial * cone.half_angle().tan(),
3278 tol,
3279 )
3280 .ok()?;
3281 Some(
3282 ogeom_geom::LineCurve::new(ogeom_math::Axis {
3283 location,
3284 direction,
3285 })
3286 .into(),
3287 )
3288 }
3289 ogeom_geom::SurfaceGeometry::BSpline(b) => {
3290 Some(ogeom_geom::Curve::BSpline(b.iso_u_curve(at, tol).ok()?))
3291 }
3292 ogeom_geom::SurfaceGeometry::Sphere(sp) => {
3293 let sphere = sp.sphere();
3294 let frame = sphere.frame();
3295 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3296 let circle_frame = ogeom_math::Frame::new(
3299 frame.origin(),
3300 ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
3301 ogeom_math::Direction::new(radial, tol).ok()?,
3302 tol,
3303 )
3304 .ok()?;
3305 let circle = ogeom_math::Circle::new(circle_frame, sphere.radius(), tol).ok()?;
3306 Some(ogeom_geom::CircleCurve::new(circle).into())
3307 }
3308 _ => None,
3309 }
3310}
3311
3312fn seam_iso_u_curve(
3317 surface: &SurfaceGeometry,
3318 at: f64,
3319 tol: Tolerances,
3320) -> Option<ogeom_geom::Curve> {
3321 surface_iso_u_curve(surface, at, tol).or_else(|| match surface {
3322 ogeom_geom::SurfaceGeometry::Extrusion(swept) => {
3323 use ogeom_geom::Curve3d as _;
3324 let location = swept.curve().point_at(at, tol).ok()?;
3325 Some(
3326 ogeom_geom::LineCurve::new(ogeom_math::Axis {
3327 location,
3328 direction: swept.direction(),
3329 })
3330 .into(),
3331 )
3332 }
3333 _ => None,
3334 })
3335}
3336
3337fn iso_curve_parameter_at(surface: &SurfaceGeometry, v: f64) -> f64 {
3345 match surface {
3346 ogeom_geom::SurfaceGeometry::Cone(c) => v / c.cone().half_angle().cos(),
3347 _ => v,
3348 }
3349}
3350
3351#[cfg(test)]
3352#[allow(clippy::unwrap_used, clippy::expect_used)]
3353mod tests {
3354 use super::*;
3355
3356 #[test]
3361 fn a_small_ring_far_from_the_origin_turns_by_its_area() {
3362 let tol = ogeom_core::Tolerances::millimetres();
3363 let side = 0.125;
3364 for offset in [0.0, 1.0e6, 1.0e7, 1.0e8] {
3365 let mut model = Model::new();
3366 let corners = [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side)]
3367 .map(|(x, y)| Point::new(offset + x, -offset + y, 0.0));
3368 let ring = make_polygon(&mut model, &corners, true, tol).unwrap().shape;
3369 let z = ogeom_math::Vector::new(0.0, 0.0, 1.0);
3370 approx::assert_relative_eq!(
3374 wire_turn(&model, &ring, z, tol).unwrap(),
3375 2.0 * side * side,
3376 max_relative = 1e-6
3377 );
3378 }
3379 }
3380
3381 #[test]
3385 fn a_planar_face_walks_its_rings_about_its_plane() {
3386 let tol = ogeom_core::Tolerances::millimetres();
3387 let mut model = Model::new();
3388 let ring = |model: &mut Model, corners: &[(f64, f64)]| {
3389 let points: Vec<Point> = corners
3390 .iter()
3391 .map(|&(x, y)| Point::new(x, y, 0.0))
3392 .collect();
3393 make_polygon(model, &points, true, tol).unwrap().shape
3394 };
3395 let outline = ring(
3396 &mut model,
3397 &[(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0)],
3398 );
3399 let hole = ring(
3400 &mut model,
3401 &[(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)],
3402 );
3403 let plane = ogeom_math::Plane::new(ogeom_math::Frame::WORLD);
3404 let face = make_face(
3405 &mut model,
3406 ogeom_geom::PlaneSurface::new(plane).into(),
3407 &[outline.clone(), hole.clone()],
3408 tol,
3409 )
3410 .unwrap();
3411 let z = ogeom_math::Vector::new(0.0, 0.0, 1.0);
3412 let wires = model.children_of(&face.shape).unwrap();
3413 assert!(wire_turn(&model, &wires[0], z, tol).unwrap() > 0.0);
3414 assert!(wire_turn(&model, &wires[1], z, tol).unwrap() < 0.0);
3415 assert_eq!(
3416 face.history.generated(&outline),
3417 std::slice::from_ref(&face.shape)
3418 );
3419 assert_eq!(
3420 face.history.generated(&hole),
3421 std::slice::from_ref(&face.shape)
3422 );
3423 use ogeom_geom::Surface as _;
3424 let data = model.node(&face.shape).unwrap().data().as_face().unwrap();
3425 let normal = model
3426 .geometry()
3427 .surface(data.surface)
3428 .unwrap()
3429 .normal_at(0.0, 0.0, tol)
3430 .unwrap();
3431 assert!(normal.vector().dot(z) > 0.0);
3432 }
3433
3434 #[test]
3438 fn a_face_on_a_fitted_surface_gains_projected_pcurves() {
3439 let tol = ogeom_core::Tolerances::millimetres();
3440 let mut model = Model::new();
3441 let rows: Vec<Vec<ogeom_math::Point>> = (0..5)
3443 .map(|j| {
3444 (0..5)
3445 .map(|i| {
3446 let (x, y) = (f64::from(i) * 2.5, f64::from(j) * 2.5);
3447 ogeom_math::Point::new(x, y, 0.4 * (x * 0.7).sin() * (y * 0.5).cos())
3448 })
3449 .collect()
3450 })
3451 .collect();
3452 let surface: ogeom_geom::SurfaceGeometry =
3453 ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, tol)
3454 .unwrap()
3455 .curve
3456 .into();
3457 use ogeom_geom::Surface as _;
3459 let ((ua, ub), (va, vb)) = surface.domain();
3460 let iso = |fixed_u: Option<f64>, fixed_v: Option<f64>| -> Curve {
3461 const N: usize = 33;
3462 let pts: Vec<ogeom_math::Point> = (0..N)
3463 .map(|k| {
3464 let t = f64::from(u32::try_from(k).unwrap())
3465 / f64::from(u32::try_from(N - 1).unwrap());
3466 let (u, v) = match (fixed_u, fixed_v) {
3467 (Some(u), None) => (u, va + (vb - va) * t),
3468 (None, Some(v)) => (ua + (ub - ua) * t, v),
3469 _ => unreachable!(),
3470 };
3471 surface.point_at(u, v, tol).unwrap()
3472 })
3473 .collect();
3474 Curve::BSpline(
3475 ogeom_geom::fit::fit_points(&pts, 3, 1e-9, tol)
3476 .unwrap()
3477 .curve,
3478 )
3479 };
3480 let corners: Vec<Shape> = [(ua, va), (ub, va), (ub, vb), (ua, vb)]
3483 .into_iter()
3484 .map(|(u, v)| {
3485 let p = surface.point_at(u, v, tol).unwrap();
3486 let vertex = model.add_vertex(ogeom_topo::VertexData::new(p));
3487 if let Some(node) = model.node_mut(&vertex)
3488 && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
3489 {
3490 data.tolerance = data.tolerance.widen_to(1e-4);
3491 }
3492 vertex
3493 })
3494 .collect();
3495 let ring = [
3496 (iso(None, Some(va)), 0usize, 1usize),
3497 (iso(Some(ub), None), 1, 2),
3498 (iso(None, Some(vb)), 3, 2),
3499 (iso(Some(ua), None), 0, 3),
3500 ];
3501 let edges: Vec<Shape> = ring
3502 .into_iter()
3503 .enumerate()
3504 .map(|(k, (c, from, to))| {
3505 use ogeom_geom::Curve3d as _;
3506 let domain = c.domain();
3507 let edge =
3508 make_edge_between(&mut model, c, domain, &corners[from], &corners[to], tol)
3509 .unwrap()
3510 .shape;
3511 if k >= 2 { edge.reversed() } else { edge }
3512 })
3513 .collect();
3514 let built =
3515 make_face_with_pcurves(&mut model, surface, std::slice::from_ref(&edges), tol).unwrap();
3516 let data = model
3518 .node(&built.shape)
3519 .unwrap()
3520 .data()
3521 .as_face()
3522 .unwrap()
3523 .clone();
3524 for edge in &edges {
3525 let e = model.node(edge).unwrap().data().as_edge().unwrap();
3526 assert!(
3527 e.pcurve_for(data.surface, edge.location()).is_some(),
3528 "a fitted trim was attached"
3529 );
3530 }
3531 }
3532
3533 #[test]
3534 fn a_band_whose_rings_start_apart_gets_a_helical_connector() {
3535 let mut model = Model::new();
3539 let radius = 2.0;
3540 let cylinder = ogeom_geom::CylinderSurface::new(
3541 ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
3542 (-1.0, 4.0),
3543 )
3544 .unwrap();
3545 let surface: SurfaceGeometry = cylinder.into();
3546
3547 let ring_at = |model: &mut Model, height: f64, phase: f64| {
3548 let x = ogeom_math::Direction::new(
3549 ogeom_math::Vector::new(phase.cos(), phase.sin(), 0.0),
3550 T,
3551 )
3552 .unwrap();
3553 let frame =
3554 Frame::new(Point::new(0.0, 0.0, height), ogeom_math::Direction::Z, x, T).unwrap();
3555 let curve: Curve = CircleCurve::new(Circle::new(frame, radius, T).unwrap()).into();
3556 let domain = curve.domain();
3557 let edge = make_edge(model, curve, domain, T).unwrap().shape;
3558 let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
3559 ogeom_math::Axis2::new(
3560 ogeom_math::Point2::new(phase, height),
3561 ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
3562 ),
3563 0.0,
3564 core::f64::consts::TAU,
3565 )
3566 .unwrap()
3567 .into();
3568 (edge, pcurve)
3569 };
3570 let (lo, lo_pcurve) = ring_at(&mut model, 0.0, 0.0);
3571 let (hi, hi_pcurve) = ring_at(&mut model, 2.0, 0.4);
3572
3573 let band = make_band_between(
3574 &mut model,
3575 &surface,
3576 [(&lo, lo_pcurve), (&hi, hi_pcurve)],
3577 T,
3578 )
3579 .unwrap();
3580
3581 assert_eq!(
3582 explore_unique(&model, &band, ShapeType::Edge)
3583 .unwrap()
3584 .len(),
3585 3
3586 );
3587 let helix = explore_unique(&model, &band, ShapeType::Edge)
3589 .unwrap()
3590 .into_iter()
3591 .find_map(|e| {
3592 let data = model.node(&e)?.data().as_edge()?;
3593 let ogeom_topo::EdgeRepr::Curve3d { curve, .. } = data.curve3d()? else {
3594 return None;
3595 };
3596 match model.geometry().curve(*curve)? {
3597 Curve::Helix(h) => Some(*h),
3598 _ => None,
3599 }
3600 })
3601 .expect("the connector is a helix");
3602 use ogeom_geom::Curve3d as _;
3603 let at_start = Curve::Helix(helix).point_at(0.0, T).unwrap();
3604 let at_end = Curve::Helix(helix).point_at(0.4, T).unwrap();
3605 assert!(at_start.distance(Point::new(2.0, 0.0, 0.0)) < 1e-9);
3606 assert!(at_end.distance(Point::new(2.0 * 0.4_f64.cos(), 2.0 * 0.4_f64.sin(), 2.0)) < 1e-9);
3607 }
3608
3609 #[test]
3613 fn a_fitted_trim_states_its_gap_along_the_whole_edge() {
3614 let mut model = Model::new();
3615 let patch = ogeom_geom::BSplineSurface::new(
3616 ogeom_math::KnotVector::clamped_uniform(1, 2).unwrap(),
3617 ogeom_math::KnotVector::clamped_uniform(1, 2).unwrap(),
3618 &ogeom_math::ControlGrid::new(
3619 vec![
3620 Point::new(-1.0, -1.0, 0.0),
3621 Point::new(-1.0, 11.0, 0.0),
3622 Point::new(11.0, -1.0, 0.0),
3623 Point::new(11.0, 11.0, 0.0),
3624 ],
3625 2,
3626 2,
3627 )
3628 .unwrap(),
3629 T,
3630 )
3631 .unwrap();
3632 const SPANS: usize = 300;
3633 let control: Vec<Point> = (0..SPANS + 3)
3634 .map(|i| {
3635 #[allow(clippy::cast_precision_loss)]
3636 let x = 10.0 * i as f64 / (SPANS + 2) as f64;
3637 let y = if i == 0 || i == SPANS + 2 {
3638 0.0
3639 } else if i.is_multiple_of(2) {
3640 0.03
3641 } else {
3642 -0.03
3643 };
3644 Point::new(x, y, 0.0)
3645 })
3646 .collect();
3647 let wiggle: Curve = ogeom_geom::BSplineCurve::new(
3648 ogeom_math::KnotVector::clamped_uniform(3, control.len()).unwrap(),
3649 control,
3650 T,
3651 )
3652 .unwrap()
3653 .into();
3654 let corners = [
3655 Point::new(0.0, 0.0, 0.0),
3656 Point::new(10.0, 0.0, 0.0),
3657 Point::new(10.0, 10.0, 0.0),
3658 Point::new(0.0, 10.0, 0.0),
3659 ];
3660 let v: Vec<Shape> = corners
3661 .iter()
3662 .map(|p| make_vertex(&mut model, *p).shape)
3663 .collect();
3664 let mut edges = vec![
3665 make_edge_between(&mut model, wiggle, (0.0, 1.0), &v[0], &v[1], T)
3666 .unwrap()
3667 .shape,
3668 ];
3669 for i in 1..4 {
3670 let (a, b) = (corners[i], corners[(i + 1) % 4]);
3671 let line: Curve = LineCurve::segment(a, b, T).unwrap().into();
3672 edges.push(
3673 make_edge_between(
3674 &mut model,
3675 line,
3676 (0.0, a.distance(b)),
3677 &v[i],
3678 &v[(i + 1) % 4],
3679 T,
3680 )
3681 .unwrap()
3682 .shape,
3683 );
3684 }
3685 let face = make_face_with_pcurves(&mut model, SurfaceGeometry::BSpline(patch), &[edges], T)
3686 .unwrap()
3687 .shape;
3688 for edge in explore_unique(&model, &face, ShapeType::Edge).unwrap() {
3689 let stated = model
3690 .node(&edge)
3691 .unwrap()
3692 .data()
3693 .as_edge()
3694 .unwrap()
3695 .tolerance
3696 .get();
3697 let gap = crate::edge_pcurve_gap(&model, &edge, T).unwrap().unwrap();
3698 assert!(gap <= stated, "a trim {gap} off an edge stating {stated}");
3699 }
3700 let diagnosis = crate::check(&model, &face, T).unwrap();
3701 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
3702 }
3703
3704 #[test]
3708 fn a_fitted_connector_states_its_charts_gap() {
3709 let mut model = Model::new();
3710 let radius = 5.0;
3711 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(
3712 ogeom_math::Sphere::new(Frame::WORLD, radius, T).unwrap(),
3713 )
3714 .into();
3715 let ring_at = |model: &mut Model, lat: f64, phase: f64| {
3716 let x = ogeom_math::Direction::new(
3717 ogeom_math::Vector::new(phase.cos(), phase.sin(), 0.0),
3718 T,
3719 )
3720 .unwrap();
3721 let frame = Frame::new(
3722 Point::new(0.0, 0.0, radius * lat.sin()),
3723 ogeom_math::Direction::Z,
3724 x,
3725 T,
3726 )
3727 .unwrap();
3728 let curve: Curve =
3729 CircleCurve::new(Circle::new(frame, radius * lat.cos(), T).unwrap()).into();
3730 let domain = curve.domain();
3731 let edge = make_edge(model, curve, domain, T).unwrap().shape;
3732 let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
3733 ogeom_math::Axis2::new(
3734 ogeom_math::Point2::new(phase, lat),
3735 ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
3736 ),
3737 0.0,
3738 core::f64::consts::TAU,
3739 )
3740 .unwrap()
3741 .into();
3742 (edge, pcurve)
3743 };
3744 let (lo, lo_pcurve) = ring_at(&mut model, -1.2, 0.0);
3745 let (hi, hi_pcurve) = ring_at(&mut model, 1.2, 3.0);
3746 let band = make_band_between(
3747 &mut model,
3748 &surface,
3749 [(&lo, lo_pcurve), (&hi, hi_pcurve)],
3750 T,
3751 )
3752 .unwrap();
3753 for edge in explore_unique(&model, &band, ShapeType::Edge).unwrap() {
3754 let stated = model
3755 .node(&edge)
3756 .unwrap()
3757 .data()
3758 .as_edge()
3759 .unwrap()
3760 .tolerance
3761 .get();
3762 let gap = crate::edge_pcurve_gap(&model, &edge, T).unwrap().unwrap();
3763 assert!(gap <= stated, "a chart {gap} off an edge stating {stated}");
3764 }
3765 let diagnosis = crate::check(&model, &band, T).unwrap();
3766 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
3767 }
3768
3769 #[test]
3770 fn a_band_between_a_circle_and_a_fitted_wavy_ring_closes_with_its_seam() {
3771 use ogeom_geom::Curve2d as _;
3772 use ogeom_geom::Curve3d as _;
3773 use ogeom_geom::Surface as _;
3774
3775 let mut model = Model::new();
3776 let radius = 2.0;
3777 let cylinder = ogeom_geom::CylinderSurface::new(
3778 ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
3779 (-1.0, 4.0),
3780 )
3781 .unwrap();
3782 let surface: SurfaceGeometry = cylinder.into();
3783
3784 let lo_curve: Curve =
3786 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into();
3787 let lo_domain = lo_curve.domain();
3788 let lo = make_edge(&mut model, lo_curve, lo_domain, T).unwrap().shape;
3789 let lo_pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
3790 ogeom_math::Axis2::new(
3791 ogeom_math::Point2::new(0.0, 0.0),
3792 ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
3793 ),
3794 lo_domain.0,
3795 lo_domain.1,
3796 )
3797 .unwrap()
3798 .into();
3799
3800 let tau = core::f64::consts::TAU;
3803 let samples = 96;
3804 let mut points = Vec::with_capacity(samples + 1);
3805 let mut chart = Vec::with_capacity(samples + 1);
3806 for i in 0..=samples {
3807 #[allow(clippy::cast_precision_loss)]
3808 let u = tau * (i as f64) / (samples as f64);
3809 let v = (3.0 * u).sin().mul_add(0.2, 1.5);
3810 points.push(Point::new(radius * u.cos(), radius * u.sin(), v));
3811 chart.push(ogeom_math::Point2::new(u, v));
3812 }
3813 let (fitted, hi_chart, _) =
3814 ogeom_geom::fit::fit_points_joint_closed(&points, &chart, &chart, 3, 1e-4, T).unwrap();
3815 assert!(fitted.met, "the wavy ring fit should meet its tolerance");
3816 let hi_curve: Curve = fitted.curve.into();
3817 let hi_domain = hi_curve.domain();
3818 let hi = make_edge(&mut model, hi_curve.clone(), hi_domain, T)
3819 .unwrap()
3820 .shape;
3821
3822 let band = make_band_between(
3823 &mut model,
3824 &surface,
3825 [(&lo, lo_pcurve), (&hi, hi_chart.into())],
3826 T,
3827 )
3828 .unwrap();
3829
3830 let occurrences =
3833 ogeom_topo::explore(&model, &band, ogeom_topo::Filter::OfType(ShapeType::Edge))
3834 .unwrap();
3835 assert_eq!(occurrences.len(), 4);
3836 assert_eq!(
3837 explore_unique(&model, &band, ShapeType::Edge)
3838 .unwrap()
3839 .len(),
3840 3
3841 );
3842
3843 let hi_pcurve = {
3846 let data = model.node(&hi).unwrap().data().as_edge().unwrap();
3847 let repr = data
3848 .representations
3849 .iter()
3850 .find_map(|r| match r {
3851 ogeom_topo::EdgeRepr::PCurve { curve, .. } => Some(*curve),
3852 _ => None,
3853 })
3854 .expect("the ring carries its pcurve");
3855 model.geometry().pcurve(repr).unwrap().clone()
3856 };
3857 for i in 0..8 {
3858 #[allow(clippy::cast_precision_loss)]
3859 let t = hi_domain.0 + (hi_domain.1 - hi_domain.0) * (i as f64) / 8.0;
3860 let uv = hi_pcurve.point_at(t, T).unwrap();
3861 let through_chart = surface.point_at(uv.x, uv.y, T).unwrap();
3862 let direct = hi_curve.point_at(t, T).unwrap();
3863 assert!(
3864 through_chart.distance(direct) < 1e-4,
3865 "same-parameter drift at t = {t}"
3866 );
3867 }
3868 }
3869
3870 use ogeom_geom::{CircleCurve, LineCurve, PlaneSurface};
3871 use ogeom_math::{Circle, Frame, Plane};
3872 use ogeom_topo::explore_unique;
3873
3874 const T: Tolerances = Tolerances::millimetres();
3875
3876 fn segment(a: Point, b: Point) -> Curve {
3877 LineCurve::segment(a, b, T).unwrap().into()
3878 }
3879
3880 fn square_edges(model: &mut Model) -> Vec<Shape> {
3882 let corners = [
3883 Point::new(0.0, 0.0, 0.0),
3884 Point::new(1.0, 0.0, 0.0),
3885 Point::new(1.0, 1.0, 0.0),
3886 Point::new(0.0, 1.0, 0.0),
3887 ];
3888 let mut vertices: Vec<Shape> = corners
3889 .iter()
3890 .map(|p| model.add_vertex(VertexData::new(*p)))
3891 .collect();
3892 vertices.push(vertices[0].clone());
3893
3894 (0..4)
3895 .map(|i| {
3896 let curve = segment(corners[i], corners[(i + 1) % 4]);
3897 let length = corners[i].distance(corners[(i + 1) % 4]);
3898 make_edge_between(
3899 model,
3900 curve,
3901 (0.0, length),
3902 &vertices[i],
3903 &vertices[i + 1],
3904 T,
3905 )
3906 .unwrap()
3907 .shape
3908 })
3909 .collect()
3910 }
3911
3912 #[test]
3919 fn a_small_chart_miss_whose_ends_meet_in_space_lays_no_pole() {
3920 use ogeom_geom::{BSplineSurface, Line2d, SurfaceGeometry};
3921 use ogeom_math::{ControlGrid, KnotVector, Point2};
3922 let side = 0.025;
3923 let mut model = Model::new();
3924 let knots = || KnotVector::new(vec![0.0, 0.0, side, side], 1).unwrap();
3926 let grid = ControlGrid::new(
3927 vec![
3928 Point::new(0.0, 0.0, 0.0),
3929 Point::new(0.0, side, 0.0),
3930 Point::new(side, 0.0, 0.0),
3931 Point::new(side, side, 0.0),
3932 ],
3933 2,
3934 2,
3935 )
3936 .unwrap();
3937 let surface: SurfaceGeometry = BSplineSurface::new(knots(), knots(), &grid, T)
3938 .unwrap()
3939 .into();
3940 let surface_id = model.geometry_mut().add_surface(surface);
3941 let corners = [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side)];
3942 let vertices: Vec<Shape> = corners
3943 .iter()
3944 .map(|&(x, y)| model.add_vertex(VertexData::new(Point::new(x, y, 0.0))))
3945 .collect();
3946 let miss = 6e-8;
3947 let edges: Vec<Shape> = (0..4)
3948 .map(|i| {
3949 let (a, b) = (corners[i], corners[(i + 1) % 4]);
3950 let (pa, pb) = (Point::new(a.0, a.1, 0.0), Point::new(b.0, b.1, 0.0));
3951 let edge = make_edge_between(
3952 &mut model,
3953 segment(pa, pb),
3954 (0.0, side),
3955 &vertices[i],
3956 &vertices[(i + 1) % 4],
3957 T,
3958 )
3959 .unwrap()
3960 .shape;
3961 let start = if i == 1 {
3964 Point2::new(a.0, a.1 + miss)
3965 } else {
3966 Point2::new(a.0, a.1)
3967 };
3968 let end = Point2::new(b.0, b.1);
3969 attach_pcurve(
3970 &mut model,
3971 &edge,
3972 Line2d::segment(start, end, T).unwrap().into(),
3973 surface_id,
3974 Location::identity(),
3975 (0.0, start.distance(end)),
3976 )
3977 .unwrap();
3978 edge
3979 })
3980 .collect();
3981 let closed = closed_at_poles(&mut model, surface_id, &edges, T).unwrap();
3982 assert_eq!(closed.len(), 4, "no pole edge is laid in the miss");
3983 for (got, given) in closed.iter().zip(&edges) {
3984 assert!(got.is_same(given));
3985 }
3986 }
3987
3988 #[test]
3989 fn an_edge_takes_its_vertices_from_its_own_geometry() {
3990 let mut model = Model::new();
3993 let built = make_edge(
3994 &mut model,
3995 segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0)),
3996 (0.0, 5.0),
3997 T,
3998 )
3999 .unwrap();
4000
4001 let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4002 let point_of = |v: &Shape| model.node(v).unwrap().data().as_vertex().unwrap().point;
4003 assert!(point_of(&start).is_equal(Point::ORIGIN, T));
4004 assert!(point_of(&end).is_equal(Point::new(3.0, 4.0, 0.0), T));
4005 assert_eq!(built.history.generated(&built.shape).len(), 2);
4006 }
4007
4008 #[test]
4009 fn a_closed_edge_names_one_vertex_twice() {
4010 let mut model = Model::new();
4013 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
4014 let built = make_edge(&mut model, circle, (0.0, core::f64::consts::TAU), T).unwrap();
4015
4016 let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4017 assert!(start.is_same(&end), "a closed edge starts where it ends");
4018 assert_eq!(
4019 explore_unique(&model, &built.shape, ShapeType::Vertex)
4020 .unwrap()
4021 .len(),
4022 1
4023 );
4024 }
4025
4026 #[test]
4027 fn an_edge_whose_curve_does_not_reach_its_vertices_is_refused() {
4028 let mut model = Model::new();
4031 let a = model.add_vertex(VertexData::new(Point::ORIGIN));
4032 let b = model.add_vertex(VertexData::new(Point::new(10.0, 0.0, 0.0)));
4033 let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
4034
4035 assert!(
4036 make_edge_between(&mut model, curve.clone(), (0.0, 5.0), &a, &b, T).is_err(),
4037 "the curve stops half way"
4038 );
4039 let c = model.add_vertex(VertexData::new(Point::new(5.0, 0.0, 0.0)));
4040 assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &c, T).is_ok());
4041 }
4042
4043 #[test]
4044 fn a_vertex_with_a_loose_tolerance_admits_a_curve_that_stops_short() {
4045 let mut model = Model::new();
4049 let a = model.add_vertex(VertexData::new(Point::ORIGIN));
4050 let b = model
4051 .add_vertex(VertexData::with_tolerance(Point::new(5.001, 0.0, 0.0), 1e-2).unwrap());
4052 let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
4053 assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &b, T).is_ok());
4054 }
4055
4056 #[test]
4057 fn edge_vertices_follow_the_direction_of_travel() {
4058 let mut model = Model::new();
4059 let built = make_edge(
4060 &mut model,
4061 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4062 (0.0, 1.0),
4063 T,
4064 )
4065 .unwrap();
4066
4067 let (forward_start, forward_end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4068 let (back_start, back_end) = edge_vertices(&model, &built.shape.reversed())
4069 .unwrap()
4070 .unwrap();
4071
4072 assert!(back_start.is_same(&forward_end));
4073 assert!(back_end.is_same(&forward_start));
4074 }
4075
4076 #[test]
4077 fn a_wire_whose_edges_do_not_meet_is_refused() {
4078 let mut model = Model::new();
4081 let joined = make_edge(
4082 &mut model,
4083 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4084 (0.0, 1.0),
4085 T,
4086 )
4087 .unwrap()
4088 .shape;
4089 let apart = make_edge(
4090 &mut model,
4091 segment(Point::new(5.0, 0.0, 0.0), Point::new(6.0, 0.0, 0.0)),
4092 (0.0, 1.0),
4093 T,
4094 )
4095 .unwrap()
4096 .shape;
4097
4098 let err = make_wire(&mut model, &[joined.clone(), apart], T).unwrap_err();
4099 assert!(err.to_string().contains("gap"), "unexpected message: {err}");
4100 assert!(make_wire(&mut model, &[joined], T).is_ok());
4101 }
4102
4103 #[test]
4104 fn a_wire_of_edges_that_meet_is_accepted_and_reported_closed() {
4105 let mut model = Model::new();
4106 let edges = square_edges(&mut model);
4107 let wire = make_wire(&mut model, &edges, T).unwrap();
4108 assert!(is_wire_closed(&model, &wire.shape, T).unwrap());
4109
4110 for edge in &edges {
4113 assert!(!wire.history.is_deleted(edge));
4114 assert_eq!(wire.history.generated(edge).len(), 1);
4115 }
4116 }
4117
4118 #[test]
4119 fn an_open_wire_is_reported_open() {
4120 let mut model = Model::new();
4121 let edges = square_edges(&mut model);
4122 let open = make_wire(&mut model, &edges[..3], T).unwrap();
4123 assert!(!is_wire_closed(&model, &open.shape, T).unwrap());
4124 }
4125
4126 #[test]
4127 fn a_face_refuses_an_open_boundary() {
4128 let mut model = Model::new();
4129 let edges = square_edges(&mut model);
4130 let open = make_wire(&mut model, &edges[..3], T).unwrap().shape;
4131 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
4132
4133 let err = make_face(&mut model, plane.clone(), &[open], T).unwrap_err();
4134 assert!(
4135 err.to_string().contains("open"),
4136 "unexpected message: {err}"
4137 );
4138
4139 let closed = make_wire(&mut model, &edges, T).unwrap().shape;
4140 assert!(make_face(&mut model, plane, &[closed], T).is_ok());
4141 }
4142
4143 #[test]
4144 fn a_natural_face_needs_no_wires_at_all() {
4145 let mut model = Model::new();
4146 let built = make_natural_face(
4147 &mut model,
4148 PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
4149 )
4150 .unwrap();
4151 let node = model.node(&built.shape).unwrap();
4152 assert!(node.data().as_face().unwrap().natural_restriction);
4153 assert!(built.history.is_empty());
4154 }
4155
4156 #[test]
4157 fn a_shell_of_one_face_is_not_closed() {
4158 let mut model = Model::new();
4160 let edges = square_edges(&mut model);
4161 let wire = make_wire(&mut model, &edges, T).unwrap().shape;
4162 let face = make_face(
4163 &mut model,
4164 PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
4165 &[wire],
4166 T,
4167 )
4168 .unwrap()
4169 .shape;
4170 let shell = make_shell(&mut model, &[face]).unwrap();
4171 assert!(!is_shell_closed(&model, &shell.shape).unwrap());
4172 }
4173
4174 #[test]
4175 fn two_faces_sharing_every_edge_form_a_closed_shell() {
4176 let mut model = Model::new();
4179 let edges = square_edges(&mut model);
4180 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
4181
4182 let front_wire = make_wire(&mut model, &edges, T).unwrap().shape;
4183 let reversed: Vec<Shape> = edges.iter().rev().map(Shape::reversed).collect();
4184 let back_wire = make_wire(&mut model, &reversed, T).unwrap().shape;
4185
4186 let front = make_face(&mut model, plane.clone(), &[front_wire], T)
4187 .unwrap()
4188 .shape;
4189 let back = make_face(&mut model, plane, &[back_wire], T).unwrap().shape;
4190 let shell = make_shell(&mut model, &[front, back]).unwrap().shape;
4191
4192 assert!(is_shell_closed(&model, &shell).unwrap());
4193 assert_eq!(
4194 explore_unique(&model, &shell, ShapeType::Edge)
4195 .unwrap()
4196 .len(),
4197 4
4198 );
4199 assert!(make_solid(&mut model, &[shell]).is_ok());
4200 }
4201
4202 #[test]
4203 fn a_pcurve_can_be_attached_after_the_edge_exists() {
4204 let mut model = Model::new();
4207 let edge = make_edge(
4208 &mut model,
4209 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4210 (0.0, 1.0),
4211 T,
4212 )
4213 .unwrap()
4214 .shape;
4215
4216 let surface = model
4217 .geometry_mut()
4218 .add_surface(PlaneSurface::new(Plane::new(Frame::WORLD)).into());
4219 let pcurve = ogeom_geom::Line2d::segment(
4220 ogeom_math::Point2::ORIGIN,
4221 ogeom_math::Point2::new(1.0, 0.0),
4222 T,
4223 )
4224 .unwrap()
4225 .into();
4226 attach_pcurve(
4227 &mut model,
4228 &edge,
4229 pcurve,
4230 surface,
4231 Location::identity(),
4232 (0.0, 1.0),
4233 )
4234 .unwrap();
4235
4236 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
4237 assert_eq!(data.representations.len(), 2, "the curve and now a pcurve");
4238 assert!(data.pcurve_on(surface).is_some());
4239 assert!(
4240 !data.same_parameter(),
4241 "the new representation has not been shown to agree with the curve"
4242 );
4243 }
4244
4245 #[test]
4246 fn degenerate_edge_ranges_are_refused() {
4247 let mut model = Model::new();
4248 let curve = segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0));
4249 assert!(make_edge(&mut model, curve.clone(), (1.0, 0.0), T).is_err());
4250 assert!(make_edge(&mut model, curve.clone(), (0.5, 0.5), T).is_err());
4251 assert!(make_edge(&mut model, curve.clone(), (0.0, f64::NAN), T).is_err());
4252 assert!(make_edge(&mut model, curve, (0.0, 1.0), T).is_ok());
4253 }
4254
4255 #[test]
4256 fn builders_reject_children_of_the_wrong_type() {
4257 let mut model = Model::new();
4258 let vertex = model.add_vertex(VertexData::new(Point::ORIGIN));
4259 assert!(make_wire(&mut model, std::slice::from_ref(&vertex), T).is_err());
4260 assert!(make_shell(&mut model, std::slice::from_ref(&vertex)).is_err());
4261 assert!(make_solid(&mut model, &[vertex]).is_err());
4262 assert!(make_wire(&mut model, &[], T).is_err());
4263 }
4264
4265 #[test]
4266 fn an_edges_vertices_record_where_they_came_from() {
4267 let mut model = Model::new();
4270 model.begin_operation();
4271 let built = make_edge(
4272 &mut model,
4273 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4274 (0.0, 1.0),
4275 T,
4276 )
4277 .unwrap();
4278
4279 let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4280 for (vertex, role) in [(start, roles::EDGE_START), (end, roles::EDGE_END)] {
4281 let provenance = model.provenance_of(&vertex).unwrap();
4282 assert!(
4283 matches!(provenance, ogeom_core::Provenance::Derived { role: r, .. } if *r == role),
4284 "expected a derived vertex with role {role:?}, got {provenance:?}"
4285 );
4286 }
4287 }
4288}
4289
4290#[cfg(test)]
4291#[allow(clippy::unwrap_used, clippy::expect_used)]
4292mod polygon_tests {
4293 use super::*;
4294 use ogeom_geom::{CircleCurve, PlaneSurface};
4295 use ogeom_math::{Circle, Frame, Plane};
4296 use ogeom_topo::explore_unique;
4297
4298 const T: Tolerances = Tolerances::millimetres();
4299
4300 fn square() -> Vec<Point> {
4301 vec![
4302 Point::new(0.0, 0.0, 0.0),
4303 Point::new(2.0, 0.0, 0.0),
4304 Point::new(2.0, 2.0, 0.0),
4305 Point::new(0.0, 2.0, 0.0),
4306 ]
4307 }
4308
4309 #[test]
4310 fn a_closed_polygon_names_its_first_vertex_again_rather_than_a_second_one() {
4311 let mut model = Model::new();
4314 let built = make_polygon(&mut model, &square(), true, T).unwrap();
4315
4316 assert_eq!(
4317 explore_unique(&model, &built.shape, ShapeType::Edge)
4318 .unwrap()
4319 .len(),
4320 4
4321 );
4322 assert_eq!(
4323 explore_unique(&model, &built.shape, ShapeType::Vertex)
4324 .unwrap()
4325 .len(),
4326 4,
4327 "four corners, not five"
4328 );
4329 assert!(is_wire_closed(&model, &built.shape, T).unwrap());
4330 }
4331
4332 #[test]
4333 fn an_open_polygon_is_one_edge_short_and_says_it_is_open() {
4334 let mut model = Model::new();
4335 let built = make_polygon(&mut model, &square(), false, T).unwrap();
4336 assert_eq!(
4337 explore_unique(&model, &built.shape, ShapeType::Edge)
4338 .unwrap()
4339 .len(),
4340 3
4341 );
4342 assert!(!is_wire_closed(&model, &built.shape, T).unwrap());
4343 }
4344
4345 #[test]
4346 fn a_polygon_that_describes_nothing_is_refused() {
4347 let mut model = Model::new();
4348 assert!(make_polygon(&mut model, &[], false, T).is_err());
4349 assert!(make_polygon(&mut model, &square()[..1], false, T).is_err());
4350 assert!(
4351 make_polygon(&mut model, &square()[..2], true, T).is_err(),
4352 "two points do not enclose anything"
4353 );
4354
4355 let mut doubled = square();
4357 doubled.insert(2, doubled[1]);
4358 assert!(make_polygon(&mut model, &doubled, true, T).is_err());
4359
4360 let mut wrapped = square();
4365 wrapped.push(wrapped[0]);
4366 for closed in [true, false] {
4367 let err = make_polygon(&mut model, &wrapped, closed, T).unwrap_err();
4368 assert!(
4369 err.to_string().contains("first and last points coincide"),
4370 "unexpected message: {err}"
4371 );
4372 }
4373 }
4374
4375 #[test]
4376 fn a_planar_shape_reports_the_plane_it_lies_in() {
4377 let mut model = Model::new();
4378 let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
4379 let plane = find_plane(&model, &wire, T)
4380 .unwrap()
4381 .expect("a flat square");
4382 assert!(
4383 plane.normal().is_parallel(ogeom_math::Direction::Z, T),
4384 "got {:?}",
4385 plane.normal()
4386 );
4387 for p in square() {
4388 assert!(plane.distance_to(p) < 1e-9);
4389 }
4390 }
4391
4392 #[test]
4393 fn a_shape_that_is_not_planar_says_so_rather_than_fitting_one_anyway() {
4394 let mut model = Model::new();
4397 let mut skew = square();
4398 skew[2] = Point::new(2.0, 2.0, 1.0);
4399 let wire = make_polygon(&mut model, &skew, true, T).unwrap().shape;
4400 assert!(find_plane(&model, &wire, T).unwrap().is_none());
4401 }
4402
4403 #[test]
4404 fn a_curved_edge_is_sampled_along_its_length_not_only_at_its_ends() {
4405 let mut model = Model::new();
4408 let circle = Circle::new(
4409 Frame::new(
4410 Point::ORIGIN,
4411 ogeom_math::Direction::Z,
4412 ogeom_math::Direction::X,
4413 T,
4414 )
4415 .unwrap(),
4416 2.0,
4417 T,
4418 )
4419 .unwrap();
4420 let arc = make_edge(
4421 &mut model,
4422 CircleCurve::new(circle).into(),
4423 (0.0, std::f64::consts::PI),
4424 T,
4425 )
4426 .unwrap()
4427 .shape;
4428 assert!(find_plane(&model, &arc, T).unwrap().is_some());
4430
4431 let plane = find_plane(&model, &arc, T).unwrap().unwrap();
4434 assert!(plane.normal().is_parallel(ogeom_math::Direction::Z, T));
4435 }
4436
4437 #[test]
4438 fn a_face_is_planar_when_its_surface_is() {
4439 let mut model = Model::new();
4440 let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
4441 let face = make_face(
4442 &mut model,
4443 PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
4444 std::slice::from_ref(&wire),
4445 T,
4446 )
4447 .unwrap()
4448 .shape;
4449 assert!(find_plane(&model, &face, T).unwrap().is_some());
4450
4451 let solid = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4452 .unwrap()
4453 .shape;
4454 assert!(
4455 find_plane(&model, &solid, T).unwrap().is_none(),
4456 "a box is not planar"
4457 );
4458 }
4459}
4460#[cfg(test)]
4461#[allow(clippy::unwrap_used, clippy::expect_used)]
4462mod compsolid_tests {
4463 use super::*;
4464 use ogeom_geom::PlaneSurface;
4465 use ogeom_math::{Frame, Plane, Point};
4466
4467 const T: Tolerances = Tolerances::millimetres();
4468
4469 fn glued_cubes(model: &mut Model) -> (Shape, Shape) {
4471 let a = crate::make_box(model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4472 .unwrap()
4473 .shape;
4474 let shared = ogeom_topo::explore(model, &a, ogeom_topo::Filter::OfType(ShapeType::Face))
4476 .unwrap()
4477 .into_iter()
4478 .find(|f| {
4479 ogeom_topo::explore(model, f, ogeom_topo::Filter::OfType(ShapeType::Vertex))
4480 .unwrap()
4481 .iter()
4482 .all(|v| {
4483 let p = model.node(v).unwrap().data().as_vertex().unwrap().point;
4484 let world = v.transform(model.datums()).unwrap().apply(p);
4485 (world.x - 1.0).abs() < 1e-9
4486 })
4487 })
4488 .expect("the box has a face at x = 1");
4489
4490 let corner = |x: f64, y: f64, z: f64| Point::new(x, y, z);
4493 let quad = |model: &mut Model, points: [Point; 4]| -> Shape {
4494 let wire = crate::make_polygon(model, &points, true, T).unwrap().shape;
4495 let plane = crate::find_plane(model, &wire, T).unwrap().unwrap();
4496 make_face(
4497 model,
4498 PlaneSurface::over(Plane::new(plane.frame()), (-4.0, 4.0), (-4.0, 4.0))
4499 .unwrap()
4500 .into(),
4501 std::slice::from_ref(&wire),
4502 T,
4503 )
4504 .unwrap()
4505 .shape
4506 };
4507 let faces = [
4508 quad(
4509 model,
4510 [
4511 corner(2.0, 0.0, 0.0),
4512 corner(2.0, 1.0, 0.0),
4513 corner(2.0, 1.0, 1.0),
4514 corner(2.0, 0.0, 1.0),
4515 ],
4516 ),
4517 quad(
4518 model,
4519 [
4520 corner(1.0, 0.0, 0.0),
4521 corner(2.0, 0.0, 0.0),
4522 corner(2.0, 0.0, 1.0),
4523 corner(1.0, 0.0, 1.0),
4524 ],
4525 ),
4526 quad(
4527 model,
4528 [
4529 corner(1.0, 1.0, 0.0),
4530 corner(1.0, 1.0, 1.0),
4531 corner(2.0, 1.0, 1.0),
4532 corner(2.0, 1.0, 0.0),
4533 ],
4534 ),
4535 quad(
4536 model,
4537 [
4538 corner(1.0, 0.0, 0.0),
4539 corner(1.0, 1.0, 0.0),
4540 corner(2.0, 1.0, 0.0),
4541 corner(2.0, 0.0, 0.0),
4542 ],
4543 ),
4544 quad(
4545 model,
4546 [
4547 corner(1.0, 0.0, 1.0),
4548 corner(2.0, 0.0, 1.0),
4549 corner(2.0, 1.0, 1.0),
4550 corner(1.0, 1.0, 1.0),
4551 ],
4552 ),
4553 ];
4554 let mut shell_faces = vec![shared.reversed()];
4555 shell_faces.extend(faces);
4556 let shell = make_shell(model, &shell_faces).unwrap().shape;
4557 let b = make_solid(model, std::slice::from_ref(&shell))
4558 .unwrap()
4559 .shape;
4560 (a, b)
4561 }
4562
4563 #[test]
4564 fn glued_solids_build_a_compsolid_and_loose_ones_are_refused() {
4565 let mut model = Model::new();
4566 let (a, b) = glued_cubes(&mut model);
4567 let built = make_compsolid(&mut model, &[a.clone(), b.clone()]).unwrap();
4568 assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::CompSolid);
4569 assert_eq!(
4570 built.history.generated(&a),
4571 std::slice::from_ref(&built.shape)
4572 );
4573
4574 let mut model = Model::new();
4576 let a = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4577 .unwrap()
4578 .shape;
4579 let far = Frame::new(
4580 Point::new(5.0, 0.0, 0.0),
4581 ogeom_math::Direction::Z,
4582 ogeom_math::Direction::X,
4583 T,
4584 )
4585 .unwrap();
4586 let b = crate::make_box(&mut model, far, (1.0, 1.0, 1.0), T)
4587 .unwrap()
4588 .shape;
4589 assert!(make_compsolid(&mut model, &[a.clone(), b]).is_err());
4590 assert!(make_compsolid(&mut model, std::slice::from_ref(&a)).is_err());
4591 }
4592}
4593
4594#[cfg(test)]
4595#[allow(clippy::unwrap_used, clippy::expect_used)]
4596mod band_tests {
4597 use super::*;
4598 use approx::assert_relative_eq;
4599 use ogeom_core::Tolerances;
4600 use ogeom_geom::CircleCurve;
4601 use ogeom_math::{Circle, Cone, Frame, Sphere};
4602
4603 const T: Tolerances = Tolerances::millimetres();
4604 const TAU: f64 = core::f64::consts::TAU;
4605
4606 fn mesh_area(model: &Model, face: &Shape) -> f64 {
4607 let fine = ogeom_mesh::Deflection {
4608 chord: 1e-3,
4609 ..ogeom_mesh::Deflection::default()
4610 };
4611 let mesh = ogeom_mesh::triangulate(model, face, fine, T).unwrap();
4612 mesh.triangles
4613 .iter()
4614 .map(|t| {
4615 let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
4616 (b - a).cross(c - a).magnitude() / 2.0
4617 })
4618 .sum()
4619 }
4620
4621 fn ring(model: &mut Model, frame: Frame, r: f64) -> Shape {
4623 let circle = Circle::new(frame, r, T).unwrap();
4624 let vertex = make_vertex(model, frame.origin() + frame.x() * r).shape;
4625 make_edge_between(
4626 model,
4627 CircleCurve::new(circle).into(),
4628 (0.0, TAU),
4629 &vertex,
4630 &vertex,
4631 T,
4632 )
4633 .unwrap()
4634 .shape
4635 }
4636
4637 fn chart_area(model: &Model, face: &Shape) -> f64 {
4640 use ogeom_geom::Curve2d as _;
4641 let surface = model.node(face).unwrap().data().as_face().unwrap().surface;
4642 let mut points = Vec::new();
4643 for wire in model
4644 .ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))
4645 .unwrap()
4646 {
4647 for edge in model.ordered_children_of(&wire).unwrap() {
4648 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
4649 let forward = edge.orientation() == ogeom_topo::Orientation::Forward;
4650 let (curve, range) = match data.pcurve_for(surface, edge.location()).unwrap() {
4651 EdgeRepr::PCurve { curve, range, .. } => (*curve, *range),
4652 EdgeRepr::Seam {
4653 forward: f,
4654 reversed: r,
4655 range,
4656 ..
4657 } => (if forward { *f } else { *r }, *range),
4658 _ => unreachable!("a pcurve was asked for"),
4659 };
4660 let pcurve = model.geometry().pcurve(curve).unwrap();
4661 for k in 0..32 {
4662 let f = f64::from(k) / 32.0;
4663 let t = if forward {
4664 range.0 + (range.1 - range.0) * f
4665 } else {
4666 range.1 + (range.0 - range.1) * f
4667 };
4668 points.push(pcurve.point_at(t, T).unwrap());
4669 }
4670 }
4671 }
4672 (0..points.len())
4673 .map(|i| {
4674 let (a, b) = (points[i], points[(i + 1) % points.len()]);
4675 a.x * b.y - b.x * a.y
4676 })
4677 .sum::<f64>()
4678 / 2.0
4679 }
4680
4681 #[test]
4686 fn a_band_keeps_its_face_on_the_left_whichever_way_its_rings_come() {
4687 let mut model = Model::new();
4688 let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
4689 let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 3.0))
4690 .unwrap()
4691 .into();
4692 let at = |z: f64, up: bool| {
4693 let axis = if up {
4694 ogeom_math::Direction::Z
4695 } else {
4696 -ogeom_math::Direction::Z
4697 };
4698 Frame::new(Point::new(0.0, 0.0, z), axis, ogeom_math::Direction::X, T).unwrap()
4699 };
4700 for (first_up, second_up) in [(true, true), (false, true), (true, false)] {
4701 let low = ring(&mut model, at(0.0, first_up), 2.0);
4702 let high = ring(&mut model, at(2.0, second_up), 2.0);
4703 for (a, b) in [(&low, &high), (&high, &low)] {
4704 let face = make_revolution_band(&mut model, &surface, a, b, T).unwrap();
4705 let area = chart_area(&model, &face);
4706 assert_relative_eq!(area, TAU * 2.0, max_relative = 1e-9);
4707 }
4708 }
4709 }
4710
4711 #[test]
4719 fn a_band_follows_its_rims_on_frames_of_either_handedness() {
4720 use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
4721 let spellings = |f: Frame| {
4722 let (o, x, y, z) = (f.origin(), f.x(), f.y(), f.z());
4723 [
4724 f,
4725 f.mirrored(),
4726 Frame::from_axes(o, x, y, -z, T).unwrap(),
4727 Frame::from_axes(o, x, -y, -z, T).unwrap(),
4728 ]
4729 };
4730 for wall in spellings(Frame::WORLD) {
4731 let cylinder = ogeom_math::Cylinder::new(wall, 2.0, T).unwrap();
4732 let heights = if wall.z().vector().z > 0.0 {
4733 (-1.0, 3.0)
4734 } else {
4735 (-3.0, 1.0)
4736 };
4737 let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, heights)
4738 .unwrap()
4739 .into();
4740 let lows = spellings(Frame::WORLD);
4741 let highs = spellings(Frame::WORLD.with_origin(Point::new(0.0, 0.0, 2.0)));
4742 let pairs = lows.into_iter().zip(highs.into_iter().rev());
4743 for ((low, high), reversed) in pairs.flat_map(|p| [(p, false), (p, true)]) {
4744 let mut model = Model::new();
4745 let mut rim = |frame: Frame| {
4746 use ogeom_geom::Reversible as _;
4747 let circle: ogeom_geom::Curve =
4748 CircleCurve::new(Circle::new(frame, 2.0, T).unwrap()).into();
4749 let circle = if reversed { circle.reversed() } else { circle };
4750 let vertex = make_vertex(&mut model, frame.origin() + frame.x() * 2.0).shape;
4751 make_edge_between(&mut model, circle, (0.0, TAU), &vertex, &vertex, T)
4752 .unwrap()
4753 .shape
4754 };
4755 let (low, high) = (rim(low), rim(high));
4756 let face = make_revolution_band(&mut model, &surface, &low, &high, T).unwrap();
4757 let id = model.node(&face).unwrap().data().as_face().unwrap().surface;
4758 for rim in [&low, &high] {
4759 let data = model.node(rim).unwrap().data().as_edge().unwrap();
4760 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
4761 unreachable!("a rim has a curve");
4762 };
4763 let curve = model.geometry().curve(*curve).unwrap();
4764 let Some(EdgeRepr::PCurve { curve: p, .. }) =
4765 data.pcurve_for(id, &Location::identity())
4766 else {
4767 unreachable!("a rim has a pcurve on the band");
4768 };
4769 let pcurve = model.geometry().pcurve(*p).unwrap();
4770 for k in 0..=16 {
4771 let t = TAU * f64::from(k) / 16.0;
4772 let uv = pcurve.point_at(t, T).unwrap();
4773 let lifted = surface.point_at(uv.x, uv.y, T).unwrap();
4774 let on = curve.point_at(t, T).unwrap();
4775 assert!(
4776 lifted.distance(on) < 1e-9,
4777 "wall {wall:?}: a rim's pcurve misses its circle by {}",
4778 lifted.distance(on)
4779 );
4780 }
4781 }
4782 assert_relative_eq!(chart_area(&model, &face), TAU * 2.0, max_relative = 1e-9);
4783 }
4784 }
4785 }
4786
4787 #[test]
4793 fn a_refused_band_leaves_its_rings_without_pcurves() {
4794 let mut model = Model::new();
4795 let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
4796 let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 3.0))
4797 .unwrap()
4798 .into();
4799 let at = |z: f64, x: ogeom_math::Direction| {
4800 Frame::new(Point::new(0.0, 0.0, z), ogeom_math::Direction::Z, x, T).unwrap()
4801 };
4802 let low = ring(&mut model, at(0.0, ogeom_math::Direction::X), 2.0);
4803 let high = ring(&mut model, at(2.0, -ogeom_math::Direction::X), 2.0);
4804 let refused = make_revolution_band(&mut model, &surface, &low, &high, T).unwrap_err();
4805 assert!(
4806 refused.to_string().contains("different columns"),
4807 "the refusal names why: {refused}"
4808 );
4809 for rim in [&low, &high] {
4810 let data = model.node(rim).unwrap().data().as_edge().unwrap();
4811 assert!(
4812 data.representations
4813 .iter()
4814 .all(|r| !matches!(r, EdgeRepr::PCurve { .. } | EdgeRepr::Seam { .. })),
4815 "a refused band left a pcurve on a rim"
4816 );
4817 }
4818 }
4819
4820 #[test]
4827 fn a_band_of_rings_seams_at_its_window_start_on_either_handedness() {
4828 use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
4829 for wall in [Frame::WORLD, Frame::WORLD.mirrored()] {
4830 let mut model = Model::new();
4831 let cylinder = ogeom_math::Cylinder::new(wall, 2.0, T).unwrap();
4832 let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 3.0))
4833 .unwrap()
4834 .into();
4835 let at = |z: f64| {
4836 Frame::new(
4837 Point::new(0.0, 0.0, z),
4838 ogeom_math::Direction::Z,
4839 ogeom_math::Direction::Y,
4840 T,
4841 )
4842 .unwrap()
4843 };
4844 let low = ring(&mut model, at(0.0), 2.0);
4845 let high = ring(&mut model, at(2.0), 2.0);
4846 let face = make_band_of_rings(
4847 &mut model,
4848 &surface,
4849 core::slice::from_ref(&low),
4850 core::slice::from_ref(&high),
4851 T,
4852 )
4853 .unwrap();
4854 let id = model.node(&face).unwrap().data().as_face().unwrap().surface;
4855 let band = model.geometry().surface(id).unwrap().clone();
4856 let ((ua, ub), _) = band.domain();
4857 for rim in [&low, &high] {
4858 let data = model.node(rim).unwrap().data().as_edge().unwrap();
4859 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
4860 unreachable!("a rim has a curve");
4861 };
4862 let curve = model.geometry().curve(*curve).unwrap();
4863 let Some(EdgeRepr::PCurve { curve: p, .. }) =
4864 data.pcurve_for(id, &Location::identity())
4865 else {
4866 unreachable!("a rim has a pcurve on the band");
4867 };
4868 let pcurve = model.geometry().pcurve(*p).unwrap();
4869 for k in 0..=16 {
4870 let t = TAU * f64::from(k) / 16.0;
4871 let uv = pcurve.point_at(t, T).unwrap();
4872 assert!(
4873 uv.x >= ua - 1e-9 && uv.x <= ub + 1e-9,
4874 "wall {wall:?}: u {} off the window",
4875 uv.x
4876 );
4877 let lifted = band.point_at(uv.x, uv.y, T).unwrap();
4878 assert!(lifted.distance(curve.point_at(t, T).unwrap()) < 1e-9);
4879 }
4880 }
4881 assert_relative_eq!(chart_area(&model, &face), TAU * 2.0, max_relative = 1e-9);
4882 }
4883 }
4884
4885 #[test]
4890 fn a_rim_split_on_the_other_rims_column_closes_the_drum() {
4891 let mut model = Model::new();
4892 let (r, h) = (2.0, 3.0);
4893 let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, r, T).unwrap();
4894 let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 4.0))
4895 .unwrap()
4896 .into();
4897 let at = |z: f64, x: ogeom_math::Direction| {
4898 Frame::new(Point::new(0.0, 0.0, z), ogeom_math::Direction::Z, x, T).unwrap()
4899 };
4900 let low = ring(&mut model, at(0.0, ogeom_math::Direction::X), r);
4901 let high = ring(&mut model, at(h, -ogeom_math::Direction::X), r);
4902 let (anchor, _) = edge_vertices(&model, &low).unwrap().unwrap();
4903
4904 let split = split_ring_on_column(&mut model, &surface, &high, &anchor, T)
4905 .unwrap()
4906 .expect("the rims stand on different columns");
4907 let pieces = model.ordered_children_of(&split.shape).unwrap();
4908 assert_eq!(pieces.len(), 2);
4909 assert_eq!(split.history.modified(&high).len(), 2);
4910 let (start, _) = edge_vertices(&model, &pieces[0]).unwrap().unwrap();
4912 let start = model
4913 .node(&start)
4914 .unwrap()
4915 .data()
4916 .as_vertex()
4917 .unwrap()
4918 .point;
4919 assert!(start.distance(Point::new(r, 0.0, h)) <= T.confusion());
4920 assert!(
4922 split_ring_on_column(&mut model, &surface, &low, &anchor, T)
4923 .unwrap()
4924 .is_none()
4925 );
4926
4927 let band = make_band_of_rings(
4928 &mut model,
4929 &surface,
4930 core::slice::from_ref(&low),
4931 &pieces,
4932 T,
4933 )
4934 .unwrap();
4935 let down = Frame::new(
4936 Point::ORIGIN,
4937 -ogeom_math::Direction::Z,
4938 ogeom_math::Direction::X,
4939 T,
4940 )
4941 .unwrap();
4942 let bottom = make_face_with_pcurves(
4943 &mut model,
4944 ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(down)).into(),
4945 &[vec![low.reversed()]],
4946 T,
4947 )
4948 .unwrap()
4949 .shape;
4950 let top = make_face_with_pcurves(
4951 &mut model,
4952 ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(at(h, ogeom_math::Direction::X)))
4953 .into(),
4954 core::slice::from_ref(&pieces),
4955 T,
4956 )
4957 .unwrap()
4958 .shape;
4959 let shell = make_shell(&mut model, &[bottom, band, top]).unwrap().shape;
4960 assert!(is_shell_closed(&model, &shell).unwrap());
4961 let solid = make_solid(&mut model, &[shell]).unwrap().shape;
4962 let diagnosis = crate::check(&model, &solid, T).unwrap();
4963 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
4964 let fine = ogeom_mesh::Deflection {
4965 chord: 1e-3,
4966 ..ogeom_mesh::Deflection::default()
4967 };
4968 let volume = crate::volume_properties(&model, &solid, fine, T)
4969 .unwrap()
4970 .mass;
4971 assert_relative_eq!(
4972 volume,
4973 core::f64::consts::PI * r * r * h,
4974 max_relative = 1e-3
4975 );
4976 }
4977
4978 #[test]
4979 fn a_cone_cap_takes_its_apex_as_a_degenerate_ring() {
4980 let mut model = Model::new();
4983 let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
4984 let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
4985 .unwrap()
4986 .into();
4987 let rim = ring(&mut model, Frame::WORLD, 2.0);
4988 let face = crate::make_apex_band(&mut model, &surface, &rim, T).unwrap();
4989 assert_relative_eq!(
4990 mesh_area(&model, &face),
4991 core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
4992 max_relative = 1e-2
4993 );
4994 }
4995
4996 #[test]
4997 fn a_ring_square_to_the_axis_is_not_a_band_ring() {
4998 let mut model = Model::new();
5004 let sphere = ogeom_math::Sphere::centred(Point::ORIGIN, 5.0, T).unwrap();
5005 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
5006 let rim_at = |model: &mut Model, x: f64| {
5008 let frame = Frame::new(
5009 Point::new(x, 0.0, 0.0),
5010 ogeom_math::Direction::X,
5011 ogeom_math::Direction::Z,
5012 T,
5013 )
5014 .unwrap();
5015 ring(model, frame, (25.0 - x * x).sqrt())
5016 };
5017 let lo = rim_at(&mut model, 3.0);
5018 let hi = rim_at(&mut model, 4.0);
5019 assert!(!crate::rings_are_parallels(&model, &surface, &[&lo, &hi], T).unwrap());
5020 assert!(make_revolution_band(&mut model, &surface, &lo, &hi, T).is_err());
5021 let p_lo = ring(
5023 &mut model,
5024 Frame::new(
5025 Point::new(0.0, 0.0, 3.0),
5026 ogeom_math::Direction::Z,
5027 ogeom_math::Direction::X,
5028 T,
5029 )
5030 .unwrap(),
5031 4.0,
5032 );
5033 let p_hi = ring(
5034 &mut model,
5035 Frame::new(
5036 Point::new(0.0, 0.0, 4.0),
5037 ogeom_math::Direction::Z,
5038 ogeom_math::Direction::X,
5039 T,
5040 )
5041 .unwrap(),
5042 3.0,
5043 );
5044 assert!(crate::rings_are_parallels(&model, &surface, &[&p_lo, &p_hi], T).unwrap());
5045 }
5046
5047 #[test]
5048 fn a_degenerate_ring_is_accepted_whichever_side_it_is_passed_on() {
5049 let mut model = Model::new();
5050 let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
5051 let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
5052 .unwrap()
5053 .into();
5054 let rim = ring(&mut model, Frame::WORLD, 2.0);
5055 let apex = make_vertex(&mut model, cone.apex()).shape;
5056 let mut data = EdgeData::new();
5057 data.degenerate = true;
5058 let degenerate = model.add_edge(data, &[apex.clone(), apex]).unwrap();
5059 let face = make_revolution_band(&mut model, &surface, °enerate, &rim, T).unwrap();
5062 assert_relative_eq!(
5063 mesh_area(&model, &face),
5064 core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
5065 max_relative = 1e-2
5066 );
5067 }
5068
5069 #[test]
5070 fn a_sphere_cap_closes_against_its_pole() {
5071 let mut model = Model::new();
5073 let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
5074 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
5075 let rim = ring(&mut model, Frame::WORLD, 3.0);
5076 let pole = make_vertex(&mut model, Point::new(0.0, 0.0, 3.0)).shape;
5077 let mut data = EdgeData::new();
5078 data.degenerate = true;
5079 let degenerate = model.add_edge(data, &[pole.clone(), pole]).unwrap();
5080 let face = make_revolution_band(&mut model, &surface, &rim, °enerate, T).unwrap();
5081 assert_relative_eq!(
5082 mesh_area(&model, &face),
5083 2.0 * core::f64::consts::PI * 9.0,
5084 max_relative = 1e-2
5085 );
5086 }
5087
5088 #[test]
5089 fn two_degenerate_rings_bound_nothing_and_are_refused() {
5090 let mut model = Model::new();
5091 let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
5092 let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
5093 .unwrap()
5094 .into();
5095 let apex = make_vertex(&mut model, cone.apex()).shape;
5096 let mut data = EdgeData::new();
5097 data.degenerate = true;
5098 let a = model
5099 .add_edge(data.clone(), &[apex.clone(), apex.clone()])
5100 .unwrap();
5101 let b = model.add_edge(data, &[apex.clone(), apex]).unwrap();
5102 assert!(make_revolution_band(&mut model, &surface, &a, &b, T).is_err());
5103 }
5104
5105 #[test]
5106 fn an_apex_band_needs_a_cone() {
5107 let mut model = Model::new();
5108 let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
5109 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
5110 let rim = ring(&mut model, Frame::WORLD, 3.0);
5111 assert!(crate::make_apex_band(&mut model, &surface, &rim, T).is_err());
5112 }
5113}
5114
5115#[cfg(test)]
5116#[allow(clippy::unwrap_used, clippy::expect_used)]
5117mod solid_tests {
5118 use super::*;
5119 use ogeom_math::Frame;
5120
5121 const T: Tolerances = Tolerances::millimetres();
5122
5123 fn box_shell(model: &mut Model, corner: f64, size: f64) -> Shape {
5125 let frame = Frame::new(
5126 Point::new(corner, corner, corner),
5127 ogeom_math::Direction::Z,
5128 ogeom_math::Direction::X,
5129 T,
5130 )
5131 .unwrap();
5132 let solid = crate::make_box(model, frame, (size, size, size), T)
5133 .unwrap()
5134 .shape;
5135 model.children_of(&solid).unwrap()[0].clone()
5136 }
5137
5138 #[test]
5143 fn a_solid_faces_out_and_its_void_faces_in_however_given() {
5144 for (outer_in, void_out) in [(false, false), (false, true), (true, false), (true, true)] {
5145 let mut model = Model::new();
5146 let outer = box_shell(&mut model, 0.0, 10.0);
5147 let void = box_shell(&mut model, 4.0, 2.0);
5148 let outer = if outer_in { outer.reversed() } else { outer };
5149 let void = if void_out { void } else { void.reversed() };
5150 let solid = make_solid(&mut model, &[outer, void]).unwrap().shape;
5151 let d = crate::check(&model, &solid, T).unwrap();
5152 assert!(d.is_valid(), "{outer_in} {void_out}: {d}");
5153 let shells = model.children_of(&solid).unwrap();
5154 assert!(enclosed_volume(&model, &shells[0]).unwrap() > 0.0);
5155 assert!(enclosed_volume(&model, &shells[1]).unwrap() < 0.0);
5156 let v = crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
5157 .unwrap()
5158 .mass;
5159 assert!((v - 992.0).abs() < 1e-9, "{outer_in} {void_out}: {v}");
5160 }
5161 }
5162}