1use std::collections::HashMap;
18
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::{Curve, Curve3d, SurfaceGeometry};
21use ogeom_math::Point;
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 make_polygon(
209 model: &mut Model,
210 points: &[Point],
211 closed: bool,
212 tol: Tolerances,
213) -> OgeomResult<Built> {
214 let least = if closed { 3 } else { 2 };
215 if points.len() < least {
216 ogeom_bail!(
217 Construction,
218 "a {} polygon needs at least {least} points, got {}",
219 if closed { "closed" } else { "open" },
220 points.len()
221 );
222 }
223 for i in 1..points.len() {
224 if points[i].is_equal(points[i - 1], tol) {
225 ogeom_bail!(
226 Construction,
227 "points {} and {i} coincide, so the edge between them has no \
228 length",
229 i - 1
230 );
231 }
232 }
233 if points[0].is_equal(points[points.len() - 1], tol) {
240 ogeom_bail!(
241 Construction,
242 "the first and last points coincide; pass `closed` and drop the \
243 repeat, or the wire gets two vertices in one place rather than a \
244 closed loop"
245 );
246 }
247
248 let mut vertices: Vec<Shape> = Vec::with_capacity(points.len());
249 for point in points {
250 vertices.push(model.add_vertex(VertexData::new(*point)));
251 }
252
253 let mut edges = Vec::with_capacity(points.len());
254 let segments = if closed {
255 points.len()
256 } else {
257 points.len() - 1
258 };
259 for i in 0..segments {
260 let (from, to) = (points[i], points[(i + 1) % points.len()]);
261 let curve: Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
262 edges.push(
263 make_edge_between(
264 model,
265 curve,
266 (0.0, from.distance(to)),
267 &vertices[i],
268 &vertices[(i + 1) % points.len()],
271 tol,
272 )?
273 .shape,
274 );
275 }
276 make_wire(model, &edges, tol)
277}
278
279pub fn find_plane(
294 model: &Model,
295 shape: &Shape,
296 tol: Tolerances,
297) -> OgeomResult<Option<ogeom_math::Plane>> {
298 let points = sample_shape(model, shape, tol)?;
299 if points.len() < 3 {
300 return Ok(None);
301 }
302 let Some((centroid, normal)) = crate::measure::least_squares_plane(&points, tol) else {
303 return Ok(None);
304 };
305 let reach = tol.confusion().max(
308 points
309 .iter()
310 .map(|p| normal.dot_vector(*p - centroid).abs())
311 .fold(0.0_f64, f64::max),
312 );
313 if reach > tol.confusion() {
314 return Ok(None);
315 }
316 Ok(Some(ogeom_math::Plane::new(ogeom_math::Frame::about(
317 centroid, normal,
318 ))))
319}
320
321fn sample_shape(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
324 const ALONG_EDGE: usize = 8;
327
328 let mut points = Vec::new();
329 for vertex in ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)? {
330 if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
331 points.push(vertex.transform(model.datums())?.apply(data.point));
332 }
333 }
334 for edge in ogeom_topo::explore_unique(model, shape, ShapeType::Edge)? {
335 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
336 continue;
337 };
338 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
339 continue;
340 };
341 let Some(geometry) = model.geometry().curve(*curve) else {
342 continue;
343 };
344 if geometry.kind() == ogeom_geom::CurveKind::Line {
345 continue;
346 }
347 let placement = edge.transform(model.datums())?;
348 for i in 1..ALONG_EDGE {
349 #[allow(clippy::cast_precision_loss)]
350 let t = i as f64 / ALONG_EDGE as f64;
351 let at = range.0 + (range.1 - range.0) * t;
352 points.push(placement.apply(geometry.point_at(at, tol)?));
353 }
354 }
355 Ok(points)
356}
357
358pub fn make_wire(model: &mut Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<Built> {
365 if edges.is_empty() {
366 ogeom_bail!(Construction, "a wire needs at least one edge");
367 }
368 check_connected(model, edges, tol)?;
369
370 let wire = model.add_wire(edges)?;
371 let mut history = History::new();
372 for edge in edges {
373 history.generate(edge, wire.clone());
377 }
378 Ok(Built::new(wire, history))
379}
380
381fn check_connected(model: &Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<()> {
383 let mut ends: Vec<Option<(Shape, Shape)>> = Vec::with_capacity(edges.len());
384 for edge in edges {
385 ends.push(edge_vertices(model, edge)?);
386 }
387 for i in 0..edges.len().saturating_sub(1) {
388 let (Some((_, end)), Some((next_start, _))) = (&ends[i], &ends[i + 1]) else {
389 ogeom_bail!(
392 Construction,
393 "edge {i} or {} has no bounding vertices, so the wire cannot be \
394 shown to connect",
395 i + 1
396 );
397 };
398 if !end.is_same(next_start)
399 && !model.same_position(end, next_start, tol)?
400 && !one_point(model, end, next_start, tol)?
401 {
402 if std::env::var("OGEOM_DEBUG_WIRE").is_ok()
403 && let (Some(a), Some(b)) = (
404 model.node(end).and_then(|n| n.data().as_vertex().cloned()),
405 model
406 .node(next_start)
407 .and_then(|n| n.data().as_vertex().cloned()),
408 )
409 {
410 eprintln!(
411 "WIRE GAP: {:?} (tol {:.2e}) vs {:?} (tol {:.2e}), gap {:.3e}",
412 a.point,
413 a.tolerance.get(),
414 b.point,
415 b.tolerance.get(),
416 a.point.distance(b.point)
417 );
418 }
419 ogeom_bail!(
420 Construction,
421 "edge {i} ends where edge {} does not begin; a wire whose edges \
422 do not meet leaves a gap in every face built on it",
423 i + 1
424 );
425 }
426 }
427 Ok(())
428}
429
430pub fn is_wire_closed(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<bool> {
436 let edges = model.children_of(wire)?;
437 let (Some(first), Some(last)) = (edges.first(), edges.last()) else {
438 return Ok(false);
439 };
440 let (Some((start, _)), Some((_, end))) =
441 (edge_vertices(model, first)?, edge_vertices(model, last)?)
442 else {
443 return Ok(false);
444 };
445 Ok(start.is_same(&end)
446 || model.same_position(&start, &end, tol)?
447 || one_point(model, &start, &end, tol)?)
448}
449
450pub(crate) fn one_point(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<bool> {
453 if a.node() != b.node() {
454 return Ok(false);
455 }
456 let Some(data) = model.node(a).and_then(|n| n.data().as_vertex()) else {
457 return Ok(false);
458 };
459 let pa = a.transform(model.datums())?.apply(data.point);
460 let pb = b.transform(model.datums())?.apply(data.point);
461 Ok(pa.distance(pb) <= data.tolerance.get().max(tol.confusion()))
462}
463
464pub fn make_face(
474 model: &mut Model,
475 surface: SurfaceGeometry,
476 wires: &[Shape],
477 tol: Tolerances,
478) -> OgeomResult<Built> {
479 for (i, wire) in wires.iter().enumerate() {
480 if model.kind_of(wire)? != ShapeType::Wire {
481 ogeom_bail!(Construction, "a face is bounded by wires");
482 }
483 if !is_wire_closed(model, wire, tol)? {
484 ogeom_bail!(
485 Construction,
486 "wire {i} is open; an open boundary encloses no area"
487 );
488 }
489 }
490
491 let id = model.geometry_mut().add_surface(surface);
492 make_face_on(model, id, wires, tol)
493}
494
495pub fn make_face_on(
507 model: &mut Model,
508 surface: ogeom_topo::SurfaceId,
509 wires: &[Shape],
510 tol: Tolerances,
511) -> OgeomResult<Built> {
512 for (i, wire) in wires.iter().enumerate() {
513 if model.kind_of(wire)? != ShapeType::Wire {
514 ogeom_bail!(Construction, "a face is bounded by wires");
515 }
516 if !is_wire_closed(model, wire, tol)? {
517 ogeom_bail!(
518 Construction,
519 "wire {i} is open; an open boundary encloses no area"
520 );
521 }
522 }
523
524 let data = FaceData::new(surface, Location::identity());
525 let face = model.add_face(data, wires)?;
526
527 let mut history = History::new();
528 for wire in wires {
529 history.generate(wire, face.clone());
530 }
531 Ok(Built::new(face, history))
532}
533
534pub fn make_face_with_pcurves(
550 model: &mut Model,
551 surface: SurfaceGeometry,
552 wires: &[Vec<Shape>],
553 tol: Tolerances,
554) -> OgeomResult<Built> {
555 let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
556 for edges in wires {
557 rings.push(make_wire(model, edges, tol)?.shape);
558 }
559 let built = make_face(model, surface.clone(), &rings, tol)?;
560 let surface_id = {
561 let Some(node) = model.node(&built.shape) else {
562 ogeom_bail!(Dangling, "the face just built is not in this model");
563 };
564 let ogeom_topo::NodeData::Face(data) = node.data() else {
565 ogeom_bail!(Construction, "the face holds no face data");
566 };
567 data.surface
568 };
569 use ogeom_geom::Curve2d as _;
577 use ogeom_geom::Surface as _;
578 let ((ua, ub), (va, vb)) = surface.domain();
579 let u_period = surface.is_periodic_u().then_some(ub - ua);
580 let v_period = surface.is_periodic_v().then_some(vb - va);
581 let mut done: Vec<Shape> = Vec::new();
582 for edges in wires {
583 let mut prev_end: Option<ogeom_math::Point2> = None;
584 for edge in edges {
585 let (curve, prange) = {
586 let Some(node) = model.node(edge) else {
587 ogeom_bail!(Dangling, "edge is not in this model");
588 };
589 let Some(data) = node.data().as_edge() else {
590 ogeom_bail!(Construction, "edge node holds no edge data");
591 };
592 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
593 ogeom_bail!(Construction, "a face edge has no 3D curve");
594 };
595 let Some(geometry) = model.geometry().curve(*curve) else {
596 ogeom_bail!(Dangling, "curve is not in this model");
597 };
598 (geometry.clone(), *range)
599 };
600 let mut pcurve = match ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) {
601 Some(exact) => exact,
602 None => {
608 let (fitted, _, _, worst_off, _) =
609 crate::pcurve_fit::fit_projected_pcurve(&curve, prange, &surface, tol)?;
610 if worst_off > tol.confusion() {
611 let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
614 model.widen(edge, widened)?;
615 if let Some((a, b)) = edge_vertices(model, edge)? {
616 model.widen(&a, widened)?;
617 model.widen(&b, widened)?;
618 }
619 }
620 fitted
621 }
622 };
623 let (t_start, t_end) = if edge.orientation() == ogeom_topo::Orientation::Reversed {
624 (prange.1, prange.0)
625 } else {
626 (prange.0, prange.1)
627 };
628 if u_period.is_some() || v_period.is_some() {
629 let start = pcurve.point_at(t_start, tol)?;
630 if let Some(prev) = prev_end {
631 let shift = ogeom_math::Vector2::new(
632 u_period.map_or(0.0, |p| ((prev.x - start.x) / p).round() * p),
633 v_period.map_or(0.0, |p| ((prev.y - start.y) / p).round() * p),
634 );
635 if shift.x != 0.0 || shift.y != 0.0 {
636 pcurve =
637 pcurve.transformed(&ogeom_math::Transform2::translation(shift), tol)?;
638 }
639 }
640 prev_end = Some(pcurve.point_at(t_end, tol)?);
641 }
642 if done.iter().any(|e| e.is_same(edge)) {
643 continue;
644 }
645 done.push(edge.clone());
646 attach_pcurve(
647 model,
648 edge,
649 pcurve,
650 surface_id,
651 Location::identity(),
652 prange,
653 )?;
654 }
655 }
656 Ok(built)
657}
658
659pub fn chain_wire_branches(
681 model: &mut Model,
682 surface: ogeom_topo::SurfaceId,
683 wires: &[Shape],
684 tol: Tolerances,
685) -> OgeomResult<()> {
686 use ogeom_geom::Curve2d as _;
687 use ogeom_geom::Surface as _;
688 let Some(geometry) = model.geometry().surface(surface) else {
689 ogeom_bail!(Dangling, "surface is not in this model");
690 };
691 let ((ua, ub), (va, vb)) = geometry.domain();
692 let u_period = geometry.is_periodic_u().then_some(ub - ua);
693 let v_period = geometry.is_periodic_v().then_some(vb - va);
694 if u_period.is_none() && v_period.is_none() {
695 return Ok(());
696 }
697 let whole = |gap: f64, period: Option<f64>| -> f64 {
700 period.map_or(0.0, |p| {
701 let k = (gap / p).round();
702 if k != 0.0 && (gap - k * p).abs() <= p * 0.25 {
703 k * p
704 } else {
705 0.0
706 }
707 })
708 };
709 type Image = (
712 Option<ogeom_topo::PCurveId>,
713 ogeom_math::Point2,
714 ogeom_math::Point2,
715 );
716 let images = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Image>> {
717 let mut out = Vec::new();
718 for edge in model.ordered_children_of(wire)? {
719 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
720 ogeom_bail!(Dangling, "edge is not in this model");
721 };
722 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
723 let (id, movable, range) = match data.pcurve_for(surface, edge.location()) {
724 Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, true, *range),
725 Some(EdgeRepr::Seam {
726 forward,
727 reversed: back,
728 range,
729 ..
730 }) => (if reversed { *back } else { *forward }, false, *range),
731 _ => continue,
732 };
733 let Some(planar) = model.geometry().pcurve(id) else {
734 ogeom_bail!(Dangling, "pcurve is not in this model");
735 };
736 let (t_start, t_end) = if reversed {
737 (range.1, range.0)
738 } else {
739 (range.0, range.1)
740 };
741 out.push((
742 movable.then_some(id),
743 planar.point_at(t_start, tol)?,
744 planar.point_at(t_end, tol)?,
745 ));
746 }
747 Ok(out)
748 };
749 let mut first_centre: Option<ogeom_math::Point2> = None;
750 for wire in wires {
751 let wire_images = images(model, wire)?;
752 if wire_images.is_empty() {
753 continue;
754 }
755 let mut shifts: Vec<(ogeom_topo::PCurveId, ogeom_math::Vector2)> = Vec::new();
758 let mut prev_end: Option<ogeom_math::Point2> = None;
759 let mut lo = ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY);
760 let mut hi = ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
761 for (id, start, end) in &wire_images {
762 let shift = match id {
763 None => ogeom_math::Vector2::new(0.0, 0.0),
764 Some(id) => match shifts.iter().find(|(seen, _)| seen == id) {
765 Some((_, s)) => *s,
766 None => {
767 let s = prev_end.map_or(ogeom_math::Vector2::new(0.0, 0.0), |prev| {
768 ogeom_math::Vector2::new(
769 whole(prev.x - start.x, u_period),
770 whole(prev.y - start.y, v_period),
771 )
772 });
773 shifts.push((*id, s));
774 s
775 }
776 },
777 };
778 let (s, e) = (*start + shift, *end + shift);
779 for p in [s, e] {
780 lo = ogeom_math::Point2::new(lo.x.min(p.x), lo.y.min(p.y));
781 hi = ogeom_math::Point2::new(hi.x.max(p.x), hi.y.max(p.y));
782 }
783 prev_end = Some(e);
784 }
785 let centre = ogeom_math::Point2::new(f64::midpoint(lo.x, hi.x), f64::midpoint(lo.y, hi.y));
787 let carry = match first_centre {
788 None => {
789 first_centre = Some(centre);
790 ogeom_math::Vector2::new(0.0, 0.0)
791 }
792 Some(first) => ogeom_math::Vector2::new(
793 u_period.map_or(0.0, |p| ((first.x - centre.x) / p).round() * p),
794 v_period.map_or(0.0, |p| ((first.y - centre.y) / p).round() * p),
795 ),
796 };
797 for (id, shift) in shifts {
798 let total = shift + carry;
799 if total.x == 0.0 && total.y == 0.0 {
800 continue;
801 }
802 let Some(planar) = model.geometry().pcurve(id) else {
803 ogeom_bail!(Dangling, "pcurve is not in this model");
804 };
805 let shifted = planar.transformed(&ogeom_math::Transform2::translation(total), tol)?;
806 if let Some(slot) = model.geometry_mut().pcurve_mut(id) {
807 *slot = shifted;
808 }
809 }
810 }
811 Ok(())
812}
813
814pub fn make_natural_face(model: &mut Model, surface: SurfaceGeometry) -> OgeomResult<Built> {
821 let id = model.geometry_mut().add_surface(surface);
822 let face = model.add_face(FaceData::natural(id, Location::identity()), &[])?;
823 Ok(Built::from_nothing(face))
824}
825
826pub fn make_compsolid(model: &mut Model, solids: &[Shape]) -> OgeomResult<Built> {
847 if solids.len() < 2 {
848 ogeom_bail!(
849 Construction,
850 "a compsolid glues at least two solids; one solid is just a solid"
851 );
852 }
853 let mut faces_of: Vec<std::collections::HashSet<ogeom_topo::TShapeId>> = Vec::new();
855 for solid in solids {
856 if model.kind_of(solid)? != ShapeType::Solid {
857 ogeom_bail!(Construction, "a compsolid's members are solids");
858 }
859 let set = ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))?
860 .iter()
861 .map(Shape::node)
862 .collect();
863 faces_of.push(set);
864 }
865 let mut joined = vec![false; solids.len()];
867 joined[0] = true;
868 let mut frontier = vec![0_usize];
869 while let Some(current) = frontier.pop() {
870 for (other, other_faces) in faces_of.iter().enumerate() {
871 if joined[other] {
872 continue;
873 }
874 if !faces_of[current].is_disjoint(other_faces) {
875 joined[other] = true;
876 frontier.push(other);
877 }
878 }
879 }
880 if joined.iter().any(|j| !j) {
881 ogeom_bail!(
882 Construction,
883 "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."
884 );
885 }
886
887 let compsolid = model.add_compsolid(solids)?;
888 let mut history = History::new();
889 for solid in solids {
890 history.generate(solid, compsolid.clone());
891 }
892 Ok(Built::new(compsolid, history))
893}
894
895pub fn make_compound(model: &mut Model, shapes: &[Shape]) -> OgeomResult<Built> {
905 let compound = model.add_compound(shapes)?;
906 let mut history = History::new();
907 for shape in shapes {
908 history.generate(shape, compound.clone());
909 }
910 Ok(Built::new(compound, history))
911}
912
913pub fn make_shell(model: &mut Model, faces: &[Shape]) -> OgeomResult<Built> {
920 let shell = model.add_shell(faces)?;
921 let mut history = History::new();
922 for face in faces {
923 history.generate(face, shell.clone());
924 }
925 Ok(Built::new(shell, history))
926}
927
928pub fn make_solid(model: &mut Model, shells: &[Shape]) -> OgeomResult<Built> {
937 let solid = model.add_solid(shells)?;
938 let mut history = History::new();
939 for shell in shells {
940 history.generate(shell, solid.clone());
941 }
942 Ok(Built::new(solid, history))
943}
944
945pub fn is_shell_closed(model: &Model, shell: &Shape) -> OgeomResult<bool> {
956 let mut uses: HashMap<ogeom_topo::TShapeId, usize> = HashMap::new();
961 for face in ogeom_topo::explore(model, shell, ogeom_topo::Filter::OfType(ShapeType::Face))? {
962 for wire in model.children_of(&face)? {
963 for edge in model.children_of(&wire)? {
964 if model
969 .node(&edge)
970 .and_then(|n| n.data().as_edge())
971 .is_some_and(|d| d.degenerate)
972 {
973 continue;
974 }
975 *uses.entry(edge.node()).or_default() += 1;
976 }
977 }
978 }
979 Ok(!uses.is_empty() && uses.values().all(|n| n % 2 == 0))
980}
981
982pub fn trimmed_where_bare(model: &mut Model, face: &Shape, tol: Tolerances) -> OgeomResult<()> {
991 let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
992 ogeom_bail!(Dangling, "face is not in this model");
993 };
994 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
995 ogeom_bail!(Dangling, "face refers to a surface not in this model");
996 };
997 for edge in ogeom_topo::explore_unique(model, face, ShapeType::Edge)? {
998 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge().cloned()) else {
999 continue;
1000 };
1001 if edge_data
1002 .pcurve_for(data.surface, edge.location())
1003 .is_some()
1004 {
1005 continue;
1006 }
1007 let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
1008 continue;
1009 };
1010 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1011 continue;
1012 };
1013 use ogeom_geom::Transformable as _;
1014 let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
1015 if let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&placed, &surface, tol) {
1016 attach_pcurve(
1017 model,
1018 &edge,
1019 pcurve,
1020 data.surface,
1021 edge.location().clone(),
1022 *range,
1023 )?;
1024 }
1025 }
1026 Ok(())
1027}
1028
1029pub fn attach_pcurve(
1037 model: &mut Model,
1038 edge: &Shape,
1039 pcurve: ogeom_geom::PlanarCurve,
1040 surface: ogeom_topo::SurfaceId,
1041 location: Location,
1042 range: (f64, f64),
1043) -> OgeomResult<()> {
1044 if model.kind_of(edge)? != ShapeType::Edge {
1045 ogeom_bail!(Construction, "pcurves attach to edges");
1046 }
1047 let id = model.geometry_mut().add_pcurve(pcurve);
1048 let Some(node) = model.node_mut(edge) else {
1049 ogeom_bail!(Dangling, "edge is not in this model");
1050 };
1051 let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1052 ogeom_bail!(Construction, "edge node holds no edge data");
1053 };
1054 data.add(EdgeRepr::PCurve {
1055 curve: id,
1056 surface,
1057 location,
1058 range,
1059 });
1060 Ok(())
1061}
1062
1063pub fn attach_seam(
1077 model: &mut Model,
1078 edge: &Shape,
1079 forward: ogeom_geom::PlanarCurve,
1080 reversed: ogeom_geom::PlanarCurve,
1081 surface: ogeom_topo::SurfaceId,
1082 location: Location,
1083 range: (f64, f64),
1084) -> OgeomResult<()> {
1085 if model.kind_of(edge)? != ShapeType::Edge {
1086 ogeom_bail!(Construction, "seams attach to edges");
1087 }
1088 let forward = model.geometry_mut().add_pcurve(forward);
1089 let reversed = model.geometry_mut().add_pcurve(reversed);
1090 let Some(node) = model.node_mut(edge) else {
1091 ogeom_bail!(Dangling, "edge is not in this model");
1092 };
1093 let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1094 ogeom_bail!(Construction, "edge node holds no edge data");
1095 };
1096 data.add(EdgeRepr::Seam {
1097 forward,
1098 reversed,
1099 surface,
1100 location,
1101 range,
1102 });
1103 Ok(())
1104}
1105
1106fn circle_is_parallel_of(
1110 circle: ogeom_math::Circle,
1111 surface: &ogeom_geom::SurfaceGeometry,
1112 axis_z: ogeom_math::Vector,
1113 tol: Tolerances,
1114) -> bool {
1115 if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1116 return false;
1117 }
1118 match surface_axis_origin(surface) {
1119 Some(origin) => {
1120 let off = circle.centre() - origin;
1121 let radial = off - axis_z * off.dot(axis_z);
1122 radial.magnitude() <= tol.confusion() * 10.0
1123 }
1124 None => true,
1125 }
1126}
1127
1128pub fn rings_are_parallels(
1140 model: &Model,
1141 surface: &ogeom_geom::SurfaceGeometry,
1142 rings: &[&Shape],
1143 tol: Tolerances,
1144) -> OgeomResult<bool> {
1145 let Some(axis_z) = surface_iso_axis(surface) else {
1146 return Ok(false);
1147 };
1148 for ring in rings {
1149 let Some(node) = model.node(ring) else {
1150 ogeom_bail!(Dangling, "edge is not in this model");
1151 };
1152 let Some(data) = node.data().as_edge() else {
1153 return Ok(false);
1154 };
1155 if data.degenerate {
1156 continue;
1157 }
1158 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1159 return Ok(false);
1160 };
1161 let Some(ogeom_geom::Curve::Circle(c)) = model.geometry().curve(*curve) else {
1162 return Ok(false);
1163 };
1164 if !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
1165 return Ok(false);
1166 }
1167 }
1168 Ok(true)
1169}
1170
1171fn surface_axis_origin(surface: &ogeom_geom::SurfaceGeometry) -> Option<Point> {
1174 use ogeom_geom::SurfaceGeometry as S;
1175 match surface {
1176 S::Cylinder(c) => Some(c.cylinder().frame().origin()),
1177 S::Cone(c) => Some(c.cone().frame().origin()),
1178 S::Sphere(s) => Some(s.sphere().centre()),
1179 S::Torus(t) => Some(t.torus().frame().origin()),
1180 _ => None,
1181 }
1182}
1183
1184pub fn make_revolution_band(
1209 model: &mut Model,
1210 surface: &ogeom_geom::SurfaceGeometry,
1211 ring_lo: &Shape,
1212 ring_hi: &Shape,
1213 tol: Tolerances,
1214) -> OgeomResult<Shape> {
1215 use ogeom_geom::Surface as _;
1216
1217 let surface_id = model.geometry_mut().add_surface(surface.clone());
1218 let ((ua_dom, ub_dom), _) = surface.domain();
1219 let span = ub_dom - ua_dom;
1220 let axis_z = surface_iso_axis(surface).ok_or_else(|| {
1221 ogeom_core::ogeom_err!(Construction, "the surface has no revolution axis")
1222 })?;
1223
1224 struct Ring {
1226 edge: Shape,
1227 vertex: Shape,
1228 crange: (f64, f64),
1229 winding: f64,
1230 row: f64,
1231 degenerate: bool,
1232 }
1233 let mut rings = Vec::new();
1234 for used in [ring_lo, ring_hi] {
1235 let edge = &if used.orientation() == ogeom_topo::Orientation::Reversed {
1239 used.reversed()
1240 } else {
1241 used.clone()
1242 };
1243 let Some((vertex, other)) = edge_vertices(model, edge)? else {
1244 ogeom_bail!(Construction, "a band ring has no vertex");
1245 };
1246 if !vertex.is_same(&other) {
1247 ogeom_bail!(Construction, "a band ring is not closed");
1248 }
1249 let at = {
1250 let Some(node) = model.node(&vertex) else {
1251 ogeom_bail!(Dangling, "vertex is not in this model");
1252 };
1253 let Some(data) = node.data().as_vertex() else {
1254 ogeom_bail!(Construction, "vertex node holds no vertex data");
1255 };
1256 data.point
1257 };
1258 let degenerate = {
1259 let Some(node) = model.node(edge) else {
1260 ogeom_bail!(Dangling, "edge is not in this model");
1261 };
1262 let Some(data) = node.data().as_edge() else {
1263 ogeom_bail!(Construction, "edge node holds no edge data");
1264 };
1265 data.degenerate
1266 };
1267 if degenerate {
1268 let Some(uv) = analytic_chart_of(surface, at) else {
1273 ogeom_bail!(
1274 Construction,
1275 "a degenerate ring's row cannot be found on this surface"
1276 );
1277 };
1278 rings.push(Ring {
1279 edge: edge.clone(),
1280 vertex,
1281 crange: (0.0, span),
1282 winding: 0.0,
1283 row: uv.y,
1284 degenerate: true,
1285 });
1286 continue;
1287 }
1288 let (curve, crange) = {
1289 let Some(node) = model.node(edge) else {
1290 ogeom_bail!(Dangling, "edge is not in this model");
1291 };
1292 let Some(data) = node.data().as_edge() else {
1293 ogeom_bail!(Construction, "edge node holds no edge data");
1294 };
1295 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1296 ogeom_bail!(Construction, "a band ring has no curve");
1297 };
1298 let Some(geometry) = model.geometry().curve(*curve) else {
1299 ogeom_bail!(Dangling, "curve is not in this model");
1300 };
1301 (geometry.clone(), *range)
1302 };
1303 let ogeom_geom::Curve::Circle(c) = &curve else {
1304 ogeom_bail!(Construction, "a band ring is not a circle");
1305 };
1306 if !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
1315 ogeom_bail!(
1316 Construction,
1317 "a band ring is a circle on the surface but not a parallel \
1318 of it; its axis or centre is off the revolution axis"
1319 );
1320 }
1321 let winding = c.circle().frame().z().vector().dot(axis_z).signum();
1322 let row = match analytic_chart_of(surface, at) {
1323 Some(uv) => uv.y,
1324 None => {
1325 crate::measure::project_on_surface(surface, at, 32, tol)?
1326 .parameters
1327 .1
1328 }
1329 };
1330 rings.push(Ring {
1331 edge: edge.clone(),
1332 vertex,
1333 crange,
1334 winding,
1335 row,
1336 degenerate: false,
1337 });
1338 }
1339 if rings[0].degenerate && rings[1].degenerate {
1343 ogeom_bail!(Construction, "both band rings are degenerate");
1344 }
1345 if rings[0].degenerate {
1346 rings.swap(0, 1);
1347 }
1348 if rings[1].degenerate {
1349 rings[1].winding = -rings[0].winding;
1350 }
1351 let anchor = {
1352 let Some(node) = model.node(&rings[0].vertex) else {
1353 ogeom_bail!(Dangling, "vertex is not in this model");
1354 };
1355 let Some(data) = node.data().as_vertex() else {
1356 ogeom_bail!(Construction, "vertex node holds no vertex data");
1357 };
1358 data.point
1359 };
1360 let ua = match analytic_chart_of(surface, anchor) {
1361 Some(uv) => uv.x,
1362 None => {
1363 crate::measure::project_on_surface(surface, anchor, 32, tol)?
1364 .parameters
1365 .0
1366 }
1367 };
1368
1369 for ring in &rings {
1372 let u_start = if ring.winding > 0.0 { ua } else { ua + span };
1373 let origin =
1374 ogeom_math::Point2::new(ring.winding.mul_add(-ring.crange.0, u_start), ring.row);
1375 let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
1376 ogeom_math::Axis2::new(
1377 origin,
1378 ogeom_math::Direction2::new(ogeom_math::Vector2::new(ring.winding, 0.0), tol)?,
1379 ),
1380 ring.crange.0,
1381 ring.crange.1,
1382 )?
1383 .into();
1384 attach_pcurve(
1385 model,
1386 &ring.edge,
1387 pcurve,
1388 surface_id,
1389 Location::identity(),
1390 ring.crange,
1391 )?;
1392 }
1393
1394 let (va, vb) = (rings[0].row, rings[1].row);
1397 let Some(seam_curve) = surface_iso_u_curve(surface, ua, tol) else {
1398 ogeom_bail!(
1399 Construction,
1400 "the surface's iso-curve has no closed form; no seam can be built"
1401 );
1402 };
1403 let (range, from, to, downward) = if va <= vb {
1404 (
1405 (va, vb),
1406 rings[0].vertex.clone(),
1407 rings[1].vertex.clone(),
1408 false,
1409 )
1410 } else {
1411 (
1412 (vb, va),
1413 rings[1].vertex.clone(),
1414 rings[0].vertex.clone(),
1415 true,
1416 )
1417 };
1418 let curve_range = (
1421 iso_curve_parameter_at(surface, range.0),
1422 iso_curve_parameter_at(surface, range.1),
1423 );
1424 let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
1425
1426 let bottom_end = if rings[0].winding > 0.0 {
1429 ua + span
1430 } else {
1431 ua
1432 };
1433 let other_col = if rings[0].winding > 0.0 {
1434 ua
1435 } else {
1436 ua + span
1437 };
1438 let hi_reversed = (rings[1].winding - rings[0].winding).abs() < 0.5;
1439 let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1440 Ok(ogeom_geom::Line2d::over(
1441 ogeom_math::Axis2::new(
1442 ogeom_math::Point2::new(u, 0.0),
1443 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1444 ),
1445 range.0 - 1.0,
1446 range.1 + 1.0,
1447 )?
1448 .into())
1449 };
1450 let (forward_col, reversed_col) = if downward {
1454 (other_col, bottom_end)
1455 } else {
1456 (bottom_end, other_col)
1457 };
1458 attach_seam(
1459 model,
1460 &seam,
1461 column(forward_col)?,
1462 column(reversed_col)?,
1463 surface_id,
1464 Location::identity(),
1465 range,
1466 )?;
1467
1468 let up = if downward {
1469 seam.reversed()
1470 } else {
1471 seam.clone()
1472 };
1473 let top = if hi_reversed {
1474 rings[1].edge.reversed()
1475 } else {
1476 rings[1].edge.clone()
1477 };
1478 let ring = vec![rings[0].edge.clone(), up.clone(), top, up.reversed()];
1479 let wire = make_wire(model, &ring, tol)?.shape;
1480 Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
1481}
1482
1483pub fn make_band_between(
1504 model: &mut Model,
1505 surface: &SurfaceGeometry,
1506 rings: [(&Shape, ogeom_geom::PlanarCurve); 2],
1507 tol: Tolerances,
1508) -> OgeomResult<Shape> {
1509 use ogeom_geom::Curve2d as _;
1510 use ogeom_geom::Surface as _;
1511
1512 let surface_id = model.geometry_mut().add_surface(surface.clone());
1513 let ((ua_dom, ub_dom), _) = surface.domain();
1514 let span = ub_dom - ua_dom;
1515
1516 struct Ring {
1517 edge: Shape,
1518 vertex: Shape,
1519 crange: (f64, f64),
1520 pcurve: ogeom_geom::PlanarCurve,
1521 start: ogeom_math::Point2,
1522 }
1523 let mut prepared: Vec<Ring> = Vec::with_capacity(2);
1524 for (used, pcurve) in rings {
1525 let edge = if used.orientation() == ogeom_topo::Orientation::Reversed {
1526 used.reversed()
1527 } else {
1528 used.clone()
1529 };
1530 let Some((vertex, other)) = edge_vertices(model, &edge)? else {
1531 ogeom_bail!(Construction, "a band ring has no vertex");
1532 };
1533 if !vertex.is_same(&other) {
1534 ogeom_bail!(Construction, "a band ring is not closed");
1535 }
1536 let crange = {
1537 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1538 ogeom_bail!(Construction, "a band ring holds no edge data");
1539 };
1540 if data.degenerate {
1543 (0.0, span)
1544 } else {
1545 let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
1546 ogeom_bail!(Construction, "a band ring has no curve");
1547 };
1548 *range
1549 }
1550 };
1551 let start = pcurve.point_at(crange.0, tol)?;
1552 let end = pcurve.point_at(crange.1, tol)?;
1553 if (end.x - start.x - span).abs() > 1e-3 {
1554 ogeom_bail!(
1555 Construction,
1556 "a band ring's chart image must run the period forward in u"
1557 );
1558 }
1559 prepared.push(Ring {
1560 edge,
1561 vertex,
1562 crange,
1563 pcurve,
1564 start,
1565 });
1566 }
1567 let (start0, start1) = (prepared[0].start, prepared[1].start);
1572 let dcol = start1.x - start0.x;
1573 let stretch = {
1579 let (du, _) = surface.d1_at(start0.x, start0.y, tol)?;
1580 du.magnitude().max(1.0)
1581 };
1582 let (seam, up_is_forward, chart_from, chart_to) = if dcol.abs() * stretch <= tol.confusion() {
1583 let column = start0.x;
1584 let (va, vb) = (start0.y, start1.y);
1585 let Some(seam_curve) = surface_iso_u_curve(surface, column, tol) else {
1586 ogeom_bail!(
1587 Construction,
1588 "the surface's iso-curve has no closed form; no seam can be built"
1589 );
1590 };
1591 let (range, from, to, downward) = if va <= vb {
1592 (
1593 (va, vb),
1594 prepared[0].vertex.clone(),
1595 prepared[1].vertex.clone(),
1596 false,
1597 )
1598 } else {
1599 (
1600 (vb, va),
1601 prepared[1].vertex.clone(),
1602 prepared[0].vertex.clone(),
1603 true,
1604 )
1605 };
1606 let curve_range = (
1607 iso_curve_parameter_at(surface, range.0),
1608 iso_curve_parameter_at(surface, range.1),
1609 );
1610 for (vertex, at) in [(&from, curve_range.0), (&to, curve_range.1)] {
1613 let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
1614 ogeom_bail!(Construction, "a band ring's start vertex holds no point");
1615 };
1616 let placed = vertex.transform(model.datums())?.apply(data.point);
1617 let miss = seam_curve.point_at(at, tol)?.distance(placed);
1618 if miss > tol.confusion() {
1619 model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1620 }
1621 }
1622 let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
1623 let (a, b) = (
1624 ogeom_math::Point2::new(column, range.0),
1625 ogeom_math::Point2::new(column, range.1),
1626 );
1627 (seam, !downward, a, b)
1628 } else if !matches!(surface, SurfaceGeometry::Cylinder(_)) {
1629 let (a, b, from, to, forward) = if dcol > 0.0 {
1635 (
1636 start0,
1637 start1,
1638 prepared[0].vertex.clone(),
1639 prepared[1].vertex.clone(),
1640 true,
1641 )
1642 } else {
1643 (
1644 start1,
1645 start0,
1646 prepared[1].vertex.clone(),
1647 prepared[0].vertex.clone(),
1648 false,
1649 )
1650 };
1651 let length = (b.x - a.x).hypot(b.y - a.y);
1652 const SAMPLES: usize = 64;
1653 let mut params: Vec<f64> = Vec::with_capacity(SAMPLES + 1);
1654 let mut lifted: Vec<Point> = Vec::with_capacity(SAMPLES + 1);
1655 for i in 0..=SAMPLES {
1656 #[allow(clippy::cast_precision_loss)]
1657 let f = i as f64 / SAMPLES as f64;
1658 params.push(length * f);
1659 lifted.push(surface.point_at(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f, tol)?);
1660 }
1661 let target = tol.confusion() * 1e3;
1662 let fitted = ogeom_geom::fit::fit_points_at(¶ms, &lifted, 3, target, tol)?;
1663 if !fitted.met {
1664 ogeom_bail!(
1665 NotDone,
1666 "a band's connector reached {} against a target of {target}",
1667 fitted.error
1668 );
1669 }
1670 let connector: ogeom_geom::Curve = ogeom_geom::Curve::BSpline(fitted.curve);
1671 for (vertex, at) in [(&from, 0.0), (&to, length)] {
1676 let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
1677 ogeom_bail!(Construction, "a band ring's start vertex holds no point");
1678 };
1679 let placed = vertex.transform(model.datums())?.apply(data.point);
1680 let miss = connector.point_at(at, tol)?.distance(placed);
1681 if miss > tol.confusion() {
1682 model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1683 }
1684 }
1685 let seam = make_edge_between(model, connector, (0.0, length), &from, &to, tol)?.shape;
1686 (seam, forward, a, b)
1687 } else {
1688 let SurfaceGeometry::Cylinder(c) = surface else {
1689 ogeom_bail!(
1690 Construction,
1691 "a band whose rings start on different columns needs a chart \
1692 connector; the surface above was not a cylinder"
1693 );
1694 };
1695 let cylinder = c.cylinder();
1696 let frame = cylinder.frame();
1697 let (a, b, from, to, forward) = if dcol > 0.0 {
1700 (
1701 start0,
1702 start1,
1703 prepared[0].vertex.clone(),
1704 prepared[1].vertex.clone(),
1705 true,
1706 )
1707 } else {
1708 (
1709 start1,
1710 start0,
1711 prepared[1].vertex.clone(),
1712 prepared[0].vertex.clone(),
1713 false,
1714 )
1715 };
1716 let drow = b.y - a.y;
1717 let connector: ogeom_geom::Curve = if drow.abs() <= 1e-9 {
1718 let lifted = ogeom_math::Frame::new(
1720 frame.origin() + frame.z().vector() * a.y,
1721 frame.z(),
1722 frame.x(),
1723 tol,
1724 )?;
1725 ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(lifted, cylinder.radius(), tol)?)
1726 .into()
1727 } else {
1728 let pitch = core::f64::consts::TAU * drow / (b.x - a.x);
1731 let dropped = ogeom_math::Frame::new(
1732 frame.origin()
1733 + frame.z().vector() * (pitch / core::f64::consts::TAU).mul_add(-a.x, a.y),
1734 frame.z(),
1735 frame.x(),
1736 tol,
1737 )?;
1738 ogeom_geom::HelixCurve::over(dropped, cylinder.radius(), pitch, a.x, b.x)?.into()
1739 };
1740 let seam = make_edge_between(model, connector, (a.x, b.x), &from, &to, tol)?.shape;
1741 (seam, forward, a, b)
1742 };
1743
1744 let segment_line = |shift: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1749 let a = ogeom_math::Point2::new(chart_from.x + shift, chart_from.y);
1750 let b = ogeom_math::Point2::new(chart_to.x + shift, chart_to.y);
1751 let d = ogeom_math::Vector2::new(b.x - a.x, b.y - a.y);
1752 let m = d.x.hypot(d.y);
1753 if m <= 1e-12 {
1754 ogeom_bail!(Construction, "the seam's chart image has no length");
1755 }
1756 Ok(ogeom_geom::Line2d::over(
1757 ogeom_math::Axis2::new(a, ogeom_math::Direction2::new(d, tol)?),
1758 0.0,
1759 m,
1760 )?
1761 .into())
1762 };
1763 let (forward_shift, reversed_shift) = if up_is_forward {
1764 (span, 0.0)
1765 } else {
1766 (0.0, span)
1767 };
1768 let seam_length = (chart_to.x - chart_from.x).hypot(chart_to.y - chart_from.y);
1771 attach_seam(
1772 model,
1773 &seam,
1774 segment_line(forward_shift)?,
1775 segment_line(reversed_shift)?,
1776 surface_id,
1777 Location::identity(),
1778 (0.0, seam_length),
1779 )?;
1780 for ring in &prepared {
1781 attach_pcurve(
1782 model,
1783 &ring.edge,
1784 ring.pcurve.clone(),
1785 surface_id,
1786 Location::identity(),
1787 ring.crange,
1788 )?;
1789 }
1790
1791 let up = if up_is_forward {
1792 seam.clone()
1793 } else {
1794 seam.reversed()
1795 };
1796 let walk = vec![
1797 prepared[0].edge.clone(),
1798 up.clone(),
1799 prepared[1].edge.reversed(),
1800 up.reversed(),
1801 ];
1802 let wire = make_wire(model, &walk, tol)?.shape;
1803 Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
1804}
1805
1806pub fn make_apex_band(
1820 model: &mut Model,
1821 surface: &SurfaceGeometry,
1822 ring: &Shape,
1823 tol: Tolerances,
1824) -> OgeomResult<Shape> {
1825 let SurfaceGeometry::Cone(c) = surface else {
1826 ogeom_bail!(
1827 Construction,
1828 "only a cone has the single apex a one-ring cap implies"
1829 );
1830 };
1831 let apex = model.add_vertex(VertexData::new(c.cone().apex()));
1832 let mut data = EdgeData::new();
1833 data.degenerate = true;
1834 let edge = model.add_edge(data, &[apex.clone(), apex])?;
1835 make_revolution_band(model, surface, ring, &edge, tol)
1836}
1837
1838fn analytic_chart_of(
1847 surface: &SurfaceGeometry,
1848 p: ogeom_math::Point,
1849) -> Option<ogeom_math::Point2> {
1850 use ogeom_geom::Surface as _;
1851 let tau = core::f64::consts::TAU;
1852 let raw = match surface {
1853 SurfaceGeometry::Cylinder(s) => {
1854 let l = s.cylinder().frame().to_local(p);
1855 ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
1856 }
1857 SurfaceGeometry::Cone(s) => {
1858 let l = s.cone().frame().to_local(p);
1859 ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
1860 }
1861 SurfaceGeometry::Sphere(s) => {
1862 let sphere = s.sphere();
1863 let l = sphere.frame().to_local(p);
1864 let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
1865 ogeom_math::Point2::new(l.y.atan2(l.x), lat)
1866 }
1867 SurfaceGeometry::Torus(s) => {
1868 let torus = s.torus();
1869 let l = torus.frame().to_local(p);
1870 let radial = l.x.hypot(l.y) - torus.major_radius();
1871 ogeom_math::Point2::new(l.y.atan2(l.x), l.z.atan2(radial))
1872 }
1873 _ => return None,
1874 };
1875 let ((ua, _), (va, vb)) = surface.domain();
1876 let u = ua + (raw.x - ua).rem_euclid(tau);
1877 let v = if surface.is_periodic_v() {
1878 va + (raw.y - va).rem_euclid(vb - va)
1879 } else {
1880 raw.y
1881 };
1882 Some(ogeom_math::Point2::new(u, v))
1883}
1884
1885fn surface_iso_axis(surface: &ogeom_geom::SurfaceGeometry) -> Option<ogeom_math::Vector> {
1887 match surface {
1888 ogeom_geom::SurfaceGeometry::Cylinder(c) => Some(c.cylinder().frame().z().vector()),
1889 ogeom_geom::SurfaceGeometry::Cone(c) => Some(c.cone().frame().z().vector()),
1890 ogeom_geom::SurfaceGeometry::Torus(t) => Some(t.torus().frame().z().vector()),
1891 ogeom_geom::SurfaceGeometry::Sphere(s) => Some(s.sphere().frame().z().vector()),
1892 _ => None,
1893 }
1894}
1895
1896pub fn surface_iso_u_curve(
1903 surface: &SurfaceGeometry,
1904 at: f64,
1905 tol: Tolerances,
1906) -> Option<ogeom_geom::Curve> {
1907 match surface {
1908 ogeom_geom::SurfaceGeometry::Cylinder(c) => {
1909 let cylinder = c.cylinder();
1910 let frame = cylinder.frame();
1911 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1912 let location = frame.origin() + radial * cylinder.radius();
1913 let axis = ogeom_math::Axis {
1914 location,
1915 direction: frame.z(),
1916 };
1917 Some(ogeom_geom::LineCurve::new(axis).into())
1918 }
1919 ogeom_geom::SurfaceGeometry::Torus(t) => {
1920 let torus = t.torus();
1921 let frame = torus.frame();
1922 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1923 let centre = frame.origin() + radial * torus.major_radius();
1924 let circle_frame = ogeom_math::Frame::new(
1928 centre,
1929 ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
1930 ogeom_math::Direction::new(radial, tol).ok()?,
1931 tol,
1932 )
1933 .ok()?;
1934 let circle = ogeom_math::Circle::new(circle_frame, torus.minor_radius(), tol).ok()?;
1935 Some(ogeom_geom::CircleCurve::new(circle).into())
1936 }
1937 ogeom_geom::SurfaceGeometry::Cone(c) => {
1938 let cone = c.cone();
1939 let frame = cone.frame();
1940 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1941 let location = frame.origin() + radial * cone.radius_at(0.0);
1945 let direction = ogeom_math::Direction::new(
1946 frame.z().vector() + radial * cone.half_angle().tan(),
1947 tol,
1948 )
1949 .ok()?;
1950 Some(
1951 ogeom_geom::LineCurve::new(ogeom_math::Axis {
1952 location,
1953 direction,
1954 })
1955 .into(),
1956 )
1957 }
1958 ogeom_geom::SurfaceGeometry::BSpline(b) => {
1959 Some(ogeom_geom::Curve::BSpline(b.iso_u_curve(at, tol).ok()?))
1960 }
1961 ogeom_geom::SurfaceGeometry::Sphere(sp) => {
1962 let sphere = sp.sphere();
1963 let frame = sphere.frame();
1964 let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1965 let circle_frame = ogeom_math::Frame::new(
1968 frame.origin(),
1969 ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
1970 ogeom_math::Direction::new(radial, tol).ok()?,
1971 tol,
1972 )
1973 .ok()?;
1974 let circle = ogeom_math::Circle::new(circle_frame, sphere.radius(), tol).ok()?;
1975 Some(ogeom_geom::CircleCurve::new(circle).into())
1976 }
1977 _ => None,
1978 }
1979}
1980
1981fn iso_curve_parameter_at(surface: &SurfaceGeometry, v: f64) -> f64 {
1988 match surface {
1989 ogeom_geom::SurfaceGeometry::Cone(c) => v / c.cone().half_angle().cos(),
1990 _ => v,
1991 }
1992}
1993
1994#[cfg(test)]
1995#[allow(clippy::unwrap_used, clippy::expect_used)]
1996mod tests {
1997 use super::*;
1998
1999 #[test]
2003 fn a_face_on_a_fitted_surface_gains_projected_pcurves() {
2004 let tol = ogeom_core::Tolerances::millimetres();
2005 let mut model = Model::new();
2006 let rows: Vec<Vec<ogeom_math::Point>> = (0..5)
2008 .map(|j| {
2009 (0..5)
2010 .map(|i| {
2011 let (x, y) = (f64::from(i) * 2.5, f64::from(j) * 2.5);
2012 ogeom_math::Point::new(x, y, 0.4 * (x * 0.7).sin() * (y * 0.5).cos())
2013 })
2014 .collect()
2015 })
2016 .collect();
2017 let surface: ogeom_geom::SurfaceGeometry =
2018 ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, tol)
2019 .unwrap()
2020 .curve
2021 .into();
2022 use ogeom_geom::Surface as _;
2024 let ((ua, ub), (va, vb)) = surface.domain();
2025 let iso = |fixed_u: Option<f64>, fixed_v: Option<f64>| -> Curve {
2026 const N: usize = 33;
2027 let pts: Vec<ogeom_math::Point> = (0..N)
2028 .map(|k| {
2029 let t = f64::from(u32::try_from(k).unwrap())
2030 / f64::from(u32::try_from(N - 1).unwrap());
2031 let (u, v) = match (fixed_u, fixed_v) {
2032 (Some(u), None) => (u, va + (vb - va) * t),
2033 (None, Some(v)) => (ua + (ub - ua) * t, v),
2034 _ => unreachable!(),
2035 };
2036 surface.point_at(u, v, tol).unwrap()
2037 })
2038 .collect();
2039 Curve::BSpline(
2040 ogeom_geom::fit::fit_points(&pts, 3, 1e-9, tol)
2041 .unwrap()
2042 .curve,
2043 )
2044 };
2045 let corners: Vec<Shape> = [(ua, va), (ub, va), (ub, vb), (ua, vb)]
2048 .into_iter()
2049 .map(|(u, v)| {
2050 let p = surface.point_at(u, v, tol).unwrap();
2051 let vertex = model.add_vertex(ogeom_topo::VertexData::new(p));
2052 if let Some(node) = model.node_mut(&vertex)
2053 && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
2054 {
2055 data.tolerance = data.tolerance.widen_to(1e-4);
2056 }
2057 vertex
2058 })
2059 .collect();
2060 let ring = [
2061 (iso(None, Some(va)), 0usize, 1usize),
2062 (iso(Some(ub), None), 1, 2),
2063 (iso(None, Some(vb)), 3, 2),
2064 (iso(Some(ua), None), 0, 3),
2065 ];
2066 let edges: Vec<Shape> = ring
2067 .into_iter()
2068 .enumerate()
2069 .map(|(k, (c, from, to))| {
2070 use ogeom_geom::Curve3d as _;
2071 let domain = c.domain();
2072 let edge =
2073 make_edge_between(&mut model, c, domain, &corners[from], &corners[to], tol)
2074 .unwrap()
2075 .shape;
2076 if k >= 2 { edge.reversed() } else { edge }
2077 })
2078 .collect();
2079 let built =
2080 make_face_with_pcurves(&mut model, surface, std::slice::from_ref(&edges), tol).unwrap();
2081 let data = model
2083 .node(&built.shape)
2084 .unwrap()
2085 .data()
2086 .as_face()
2087 .unwrap()
2088 .clone();
2089 for edge in &edges {
2090 let e = model.node(edge).unwrap().data().as_edge().unwrap();
2091 assert!(
2092 e.pcurve_for(data.surface, edge.location()).is_some(),
2093 "a fitted trim was attached"
2094 );
2095 }
2096 }
2097
2098 #[test]
2099 fn a_band_whose_rings_start_apart_gets_a_helical_connector() {
2100 let mut model = Model::new();
2104 let radius = 2.0;
2105 let cylinder = ogeom_geom::CylinderSurface::new(
2106 ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
2107 (-1.0, 4.0),
2108 )
2109 .unwrap();
2110 let surface: SurfaceGeometry = cylinder.into();
2111
2112 let ring_at = |model: &mut Model, height: f64, phase: f64| {
2113 let x = ogeom_math::Direction::new(
2114 ogeom_math::Vector::new(phase.cos(), phase.sin(), 0.0),
2115 T,
2116 )
2117 .unwrap();
2118 let frame =
2119 Frame::new(Point::new(0.0, 0.0, height), ogeom_math::Direction::Z, x, T).unwrap();
2120 let curve: Curve = CircleCurve::new(Circle::new(frame, radius, T).unwrap()).into();
2121 let domain = curve.domain();
2122 let edge = make_edge(model, curve, domain, T).unwrap().shape;
2123 let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2124 ogeom_math::Axis2::new(
2125 ogeom_math::Point2::new(phase, height),
2126 ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
2127 ),
2128 0.0,
2129 core::f64::consts::TAU,
2130 )
2131 .unwrap()
2132 .into();
2133 (edge, pcurve)
2134 };
2135 let (lo, lo_pcurve) = ring_at(&mut model, 0.0, 0.0);
2136 let (hi, hi_pcurve) = ring_at(&mut model, 2.0, 0.4);
2137
2138 let band = make_band_between(
2139 &mut model,
2140 &surface,
2141 [(&lo, lo_pcurve), (&hi, hi_pcurve)],
2142 T,
2143 )
2144 .unwrap();
2145
2146 assert_eq!(
2147 explore_unique(&model, &band, ShapeType::Edge)
2148 .unwrap()
2149 .len(),
2150 3
2151 );
2152 let helix = explore_unique(&model, &band, ShapeType::Edge)
2154 .unwrap()
2155 .into_iter()
2156 .find_map(|e| {
2157 let data = model.node(&e)?.data().as_edge()?;
2158 let ogeom_topo::EdgeRepr::Curve3d { curve, .. } = data.curve3d()? else {
2159 return None;
2160 };
2161 match model.geometry().curve(*curve)? {
2162 Curve::Helix(h) => Some(*h),
2163 _ => None,
2164 }
2165 })
2166 .expect("the connector is a helix");
2167 use ogeom_geom::Curve3d as _;
2168 let at_start = Curve::Helix(helix).point_at(0.0, T).unwrap();
2169 let at_end = Curve::Helix(helix).point_at(0.4, T).unwrap();
2170 assert!(at_start.distance(Point::new(2.0, 0.0, 0.0)) < 1e-9);
2171 assert!(at_end.distance(Point::new(2.0 * 0.4_f64.cos(), 2.0 * 0.4_f64.sin(), 2.0)) < 1e-9);
2172 }
2173
2174 #[test]
2175 fn a_band_between_a_circle_and_a_fitted_wavy_ring_closes_with_its_seam() {
2176 use ogeom_geom::Curve2d as _;
2177 use ogeom_geom::Curve3d as _;
2178 use ogeom_geom::Surface as _;
2179
2180 let mut model = Model::new();
2181 let radius = 2.0;
2182 let cylinder = ogeom_geom::CylinderSurface::new(
2183 ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
2184 (-1.0, 4.0),
2185 )
2186 .unwrap();
2187 let surface: SurfaceGeometry = cylinder.into();
2188
2189 let lo_curve: Curve =
2191 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into();
2192 let lo_domain = lo_curve.domain();
2193 let lo = make_edge(&mut model, lo_curve, lo_domain, T).unwrap().shape;
2194 let lo_pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2195 ogeom_math::Axis2::new(
2196 ogeom_math::Point2::new(0.0, 0.0),
2197 ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
2198 ),
2199 lo_domain.0,
2200 lo_domain.1,
2201 )
2202 .unwrap()
2203 .into();
2204
2205 let tau = core::f64::consts::TAU;
2208 let samples = 96;
2209 let mut points = Vec::with_capacity(samples + 1);
2210 let mut chart = Vec::with_capacity(samples + 1);
2211 for i in 0..=samples {
2212 #[allow(clippy::cast_precision_loss)]
2213 let u = tau * (i as f64) / (samples as f64);
2214 let v = (3.0 * u).sin().mul_add(0.2, 1.5);
2215 points.push(Point::new(radius * u.cos(), radius * u.sin(), v));
2216 chart.push(ogeom_math::Point2::new(u, v));
2217 }
2218 let (fitted, hi_chart, _) =
2219 ogeom_geom::fit::fit_points_joint_closed(&points, &chart, &chart, 3, 1e-4, T).unwrap();
2220 assert!(fitted.met, "the wavy ring fit should meet its tolerance");
2221 let hi_curve: Curve = fitted.curve.into();
2222 let hi_domain = hi_curve.domain();
2223 let hi = make_edge(&mut model, hi_curve.clone(), hi_domain, T)
2224 .unwrap()
2225 .shape;
2226
2227 let band = make_band_between(
2228 &mut model,
2229 &surface,
2230 [(&lo, lo_pcurve), (&hi, hi_chart.into())],
2231 T,
2232 )
2233 .unwrap();
2234
2235 let occurrences =
2238 ogeom_topo::explore(&model, &band, ogeom_topo::Filter::OfType(ShapeType::Edge))
2239 .unwrap();
2240 assert_eq!(occurrences.len(), 4);
2241 assert_eq!(
2242 explore_unique(&model, &band, ShapeType::Edge)
2243 .unwrap()
2244 .len(),
2245 3
2246 );
2247
2248 let hi_pcurve = {
2251 let data = model.node(&hi).unwrap().data().as_edge().unwrap();
2252 let repr = data
2253 .representations
2254 .iter()
2255 .find_map(|r| match r {
2256 ogeom_topo::EdgeRepr::PCurve { curve, .. } => Some(*curve),
2257 _ => None,
2258 })
2259 .expect("the ring carries its pcurve");
2260 model.geometry().pcurve(repr).unwrap().clone()
2261 };
2262 for i in 0..8 {
2263 #[allow(clippy::cast_precision_loss)]
2264 let t = hi_domain.0 + (hi_domain.1 - hi_domain.0) * (i as f64) / 8.0;
2265 let uv = hi_pcurve.point_at(t, T).unwrap();
2266 let through_chart = surface.point_at(uv.x, uv.y, T).unwrap();
2267 let direct = hi_curve.point_at(t, T).unwrap();
2268 assert!(
2269 through_chart.distance(direct) < 1e-4,
2270 "same-parameter drift at t = {t}"
2271 );
2272 }
2273 }
2274
2275 use ogeom_geom::{CircleCurve, LineCurve, PlaneSurface};
2276 use ogeom_math::{Circle, Frame, Plane};
2277 use ogeom_topo::explore_unique;
2278
2279 const T: Tolerances = Tolerances::millimetres();
2280
2281 fn segment(a: Point, b: Point) -> Curve {
2282 LineCurve::segment(a, b, T).unwrap().into()
2283 }
2284
2285 fn square_edges(model: &mut Model) -> Vec<Shape> {
2287 let corners = [
2288 Point::new(0.0, 0.0, 0.0),
2289 Point::new(1.0, 0.0, 0.0),
2290 Point::new(1.0, 1.0, 0.0),
2291 Point::new(0.0, 1.0, 0.0),
2292 ];
2293 let mut vertices: Vec<Shape> = corners
2294 .iter()
2295 .map(|p| model.add_vertex(VertexData::new(*p)))
2296 .collect();
2297 vertices.push(vertices[0].clone());
2298
2299 (0..4)
2300 .map(|i| {
2301 let curve = segment(corners[i], corners[(i + 1) % 4]);
2302 let length = corners[i].distance(corners[(i + 1) % 4]);
2303 make_edge_between(
2304 model,
2305 curve,
2306 (0.0, length),
2307 &vertices[i],
2308 &vertices[i + 1],
2309 T,
2310 )
2311 .unwrap()
2312 .shape
2313 })
2314 .collect()
2315 }
2316
2317 #[test]
2318 fn an_edge_takes_its_vertices_from_its_own_geometry() {
2319 let mut model = Model::new();
2322 let built = make_edge(
2323 &mut model,
2324 segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0)),
2325 (0.0, 5.0),
2326 T,
2327 )
2328 .unwrap();
2329
2330 let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2331 let point_of = |v: &Shape| model.node(v).unwrap().data().as_vertex().unwrap().point;
2332 assert!(point_of(&start).is_equal(Point::ORIGIN, T));
2333 assert!(point_of(&end).is_equal(Point::new(3.0, 4.0, 0.0), T));
2334 assert_eq!(built.history.generated(&built.shape).len(), 2);
2335 }
2336
2337 #[test]
2338 fn a_closed_edge_names_one_vertex_twice() {
2339 let mut model = Model::new();
2342 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2343 let built = make_edge(&mut model, circle, (0.0, core::f64::consts::TAU), T).unwrap();
2344
2345 let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2346 assert!(start.is_same(&end), "a closed edge starts where it ends");
2347 assert_eq!(
2348 explore_unique(&model, &built.shape, ShapeType::Vertex)
2349 .unwrap()
2350 .len(),
2351 1
2352 );
2353 }
2354
2355 #[test]
2356 fn an_edge_whose_curve_does_not_reach_its_vertices_is_refused() {
2357 let mut model = Model::new();
2360 let a = model.add_vertex(VertexData::new(Point::ORIGIN));
2361 let b = model.add_vertex(VertexData::new(Point::new(10.0, 0.0, 0.0)));
2362 let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
2363
2364 assert!(
2365 make_edge_between(&mut model, curve.clone(), (0.0, 5.0), &a, &b, T).is_err(),
2366 "the curve stops half way"
2367 );
2368 let c = model.add_vertex(VertexData::new(Point::new(5.0, 0.0, 0.0)));
2369 assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &c, T).is_ok());
2370 }
2371
2372 #[test]
2373 fn a_vertex_with_a_loose_tolerance_admits_a_curve_that_stops_short() {
2374 let mut model = Model::new();
2378 let a = model.add_vertex(VertexData::new(Point::ORIGIN));
2379 let b = model
2380 .add_vertex(VertexData::with_tolerance(Point::new(5.001, 0.0, 0.0), 1e-2).unwrap());
2381 let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
2382 assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &b, T).is_ok());
2383 }
2384
2385 #[test]
2386 fn edge_vertices_follow_the_direction_of_travel() {
2387 let mut model = Model::new();
2388 let built = make_edge(
2389 &mut model,
2390 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2391 (0.0, 1.0),
2392 T,
2393 )
2394 .unwrap();
2395
2396 let (forward_start, forward_end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2397 let (back_start, back_end) = edge_vertices(&model, &built.shape.reversed())
2398 .unwrap()
2399 .unwrap();
2400
2401 assert!(back_start.is_same(&forward_end));
2402 assert!(back_end.is_same(&forward_start));
2403 }
2404
2405 #[test]
2406 fn a_wire_whose_edges_do_not_meet_is_refused() {
2407 let mut model = Model::new();
2410 let joined = make_edge(
2411 &mut model,
2412 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2413 (0.0, 1.0),
2414 T,
2415 )
2416 .unwrap()
2417 .shape;
2418 let apart = make_edge(
2419 &mut model,
2420 segment(Point::new(5.0, 0.0, 0.0), Point::new(6.0, 0.0, 0.0)),
2421 (0.0, 1.0),
2422 T,
2423 )
2424 .unwrap()
2425 .shape;
2426
2427 let err = make_wire(&mut model, &[joined.clone(), apart], T).unwrap_err();
2428 assert!(err.to_string().contains("gap"), "unexpected message: {err}");
2429 assert!(make_wire(&mut model, &[joined], T).is_ok());
2430 }
2431
2432 #[test]
2433 fn a_wire_of_edges_that_meet_is_accepted_and_reported_closed() {
2434 let mut model = Model::new();
2435 let edges = square_edges(&mut model);
2436 let wire = make_wire(&mut model, &edges, T).unwrap();
2437 assert!(is_wire_closed(&model, &wire.shape, T).unwrap());
2438
2439 for edge in &edges {
2442 assert!(!wire.history.is_deleted(edge));
2443 assert_eq!(wire.history.generated(edge).len(), 1);
2444 }
2445 }
2446
2447 #[test]
2448 fn an_open_wire_is_reported_open() {
2449 let mut model = Model::new();
2450 let edges = square_edges(&mut model);
2451 let open = make_wire(&mut model, &edges[..3], T).unwrap();
2452 assert!(!is_wire_closed(&model, &open.shape, T).unwrap());
2453 }
2454
2455 #[test]
2456 fn a_face_refuses_an_open_boundary() {
2457 let mut model = Model::new();
2458 let edges = square_edges(&mut model);
2459 let open = make_wire(&mut model, &edges[..3], T).unwrap().shape;
2460 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
2461
2462 let err = make_face(&mut model, plane.clone(), &[open], T).unwrap_err();
2463 assert!(
2464 err.to_string().contains("open"),
2465 "unexpected message: {err}"
2466 );
2467
2468 let closed = make_wire(&mut model, &edges, T).unwrap().shape;
2469 assert!(make_face(&mut model, plane, &[closed], T).is_ok());
2470 }
2471
2472 #[test]
2473 fn a_natural_face_needs_no_wires_at_all() {
2474 let mut model = Model::new();
2475 let built = make_natural_face(
2476 &mut model,
2477 PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
2478 )
2479 .unwrap();
2480 let node = model.node(&built.shape).unwrap();
2481 assert!(node.data().as_face().unwrap().natural_restriction);
2482 assert!(built.history.is_empty());
2483 }
2484
2485 #[test]
2486 fn a_shell_of_one_face_is_not_closed() {
2487 let mut model = Model::new();
2489 let edges = square_edges(&mut model);
2490 let wire = make_wire(&mut model, &edges, T).unwrap().shape;
2491 let face = make_face(
2492 &mut model,
2493 PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
2494 &[wire],
2495 T,
2496 )
2497 .unwrap()
2498 .shape;
2499 let shell = make_shell(&mut model, &[face]).unwrap();
2500 assert!(!is_shell_closed(&model, &shell.shape).unwrap());
2501 }
2502
2503 #[test]
2504 fn two_faces_sharing_every_edge_form_a_closed_shell() {
2505 let mut model = Model::new();
2508 let edges = square_edges(&mut model);
2509 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
2510
2511 let front_wire = make_wire(&mut model, &edges, T).unwrap().shape;
2512 let reversed: Vec<Shape> = edges.iter().rev().map(Shape::reversed).collect();
2513 let back_wire = make_wire(&mut model, &reversed, T).unwrap().shape;
2514
2515 let front = make_face(&mut model, plane.clone(), &[front_wire], T)
2516 .unwrap()
2517 .shape;
2518 let back = make_face(&mut model, plane, &[back_wire], T).unwrap().shape;
2519 let shell = make_shell(&mut model, &[front, back]).unwrap().shape;
2520
2521 assert!(is_shell_closed(&model, &shell).unwrap());
2522 assert_eq!(
2523 explore_unique(&model, &shell, ShapeType::Edge)
2524 .unwrap()
2525 .len(),
2526 4
2527 );
2528 assert!(make_solid(&mut model, &[shell]).is_ok());
2529 }
2530
2531 #[test]
2532 fn a_pcurve_can_be_attached_after_the_edge_exists() {
2533 let mut model = Model::new();
2536 let edge = make_edge(
2537 &mut model,
2538 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2539 (0.0, 1.0),
2540 T,
2541 )
2542 .unwrap()
2543 .shape;
2544
2545 let surface = model
2546 .geometry_mut()
2547 .add_surface(PlaneSurface::new(Plane::new(Frame::WORLD)).into());
2548 let pcurve = ogeom_geom::Line2d::segment(
2549 ogeom_math::Point2::ORIGIN,
2550 ogeom_math::Point2::new(1.0, 0.0),
2551 T,
2552 )
2553 .unwrap()
2554 .into();
2555 attach_pcurve(
2556 &mut model,
2557 &edge,
2558 pcurve,
2559 surface,
2560 Location::identity(),
2561 (0.0, 1.0),
2562 )
2563 .unwrap();
2564
2565 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
2566 assert_eq!(data.representations.len(), 2, "the curve and now a pcurve");
2567 assert!(data.pcurve_on(surface).is_some());
2568 assert!(
2569 !data.same_parameter(),
2570 "the new representation has not been shown to agree with the curve"
2571 );
2572 }
2573
2574 #[test]
2575 fn degenerate_edge_ranges_are_refused() {
2576 let mut model = Model::new();
2577 let curve = segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0));
2578 assert!(make_edge(&mut model, curve.clone(), (1.0, 0.0), T).is_err());
2579 assert!(make_edge(&mut model, curve.clone(), (0.5, 0.5), T).is_err());
2580 assert!(make_edge(&mut model, curve.clone(), (0.0, f64::NAN), T).is_err());
2581 assert!(make_edge(&mut model, curve, (0.0, 1.0), T).is_ok());
2582 }
2583
2584 #[test]
2585 fn builders_reject_children_of_the_wrong_type() {
2586 let mut model = Model::new();
2587 let vertex = model.add_vertex(VertexData::new(Point::ORIGIN));
2588 assert!(make_wire(&mut model, std::slice::from_ref(&vertex), T).is_err());
2589 assert!(make_shell(&mut model, std::slice::from_ref(&vertex)).is_err());
2590 assert!(make_solid(&mut model, &[vertex]).is_err());
2591 assert!(make_wire(&mut model, &[], T).is_err());
2592 }
2593
2594 #[test]
2595 fn an_edges_vertices_record_where_they_came_from() {
2596 let mut model = Model::new();
2599 model.begin_operation();
2600 let built = make_edge(
2601 &mut model,
2602 segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2603 (0.0, 1.0),
2604 T,
2605 )
2606 .unwrap();
2607
2608 let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2609 for (vertex, role) in [(start, roles::EDGE_START), (end, roles::EDGE_END)] {
2610 let provenance = model.provenance_of(&vertex).unwrap();
2611 assert!(
2612 matches!(provenance, ogeom_core::Provenance::Derived { role: r, .. } if *r == role),
2613 "expected a derived vertex with role {role:?}, got {provenance:?}"
2614 );
2615 }
2616 }
2617}
2618
2619#[cfg(test)]
2620#[allow(clippy::unwrap_used, clippy::expect_used)]
2621mod polygon_tests {
2622 use super::*;
2623 use ogeom_geom::{CircleCurve, PlaneSurface};
2624 use ogeom_math::{Circle, Frame, Plane};
2625 use ogeom_topo::explore_unique;
2626
2627 const T: Tolerances = Tolerances::millimetres();
2628
2629 fn square() -> Vec<Point> {
2630 vec![
2631 Point::new(0.0, 0.0, 0.0),
2632 Point::new(2.0, 0.0, 0.0),
2633 Point::new(2.0, 2.0, 0.0),
2634 Point::new(0.0, 2.0, 0.0),
2635 ]
2636 }
2637
2638 #[test]
2639 fn a_closed_polygon_names_its_first_vertex_again_rather_than_a_second_one() {
2640 let mut model = Model::new();
2643 let built = make_polygon(&mut model, &square(), true, T).unwrap();
2644
2645 assert_eq!(
2646 explore_unique(&model, &built.shape, ShapeType::Edge)
2647 .unwrap()
2648 .len(),
2649 4
2650 );
2651 assert_eq!(
2652 explore_unique(&model, &built.shape, ShapeType::Vertex)
2653 .unwrap()
2654 .len(),
2655 4,
2656 "four corners, not five"
2657 );
2658 assert!(is_wire_closed(&model, &built.shape, T).unwrap());
2659 }
2660
2661 #[test]
2662 fn an_open_polygon_is_one_edge_short_and_says_it_is_open() {
2663 let mut model = Model::new();
2664 let built = make_polygon(&mut model, &square(), false, T).unwrap();
2665 assert_eq!(
2666 explore_unique(&model, &built.shape, ShapeType::Edge)
2667 .unwrap()
2668 .len(),
2669 3
2670 );
2671 assert!(!is_wire_closed(&model, &built.shape, T).unwrap());
2672 }
2673
2674 #[test]
2675 fn a_polygon_that_describes_nothing_is_refused() {
2676 let mut model = Model::new();
2677 assert!(make_polygon(&mut model, &[], false, T).is_err());
2678 assert!(make_polygon(&mut model, &square()[..1], false, T).is_err());
2679 assert!(
2680 make_polygon(&mut model, &square()[..2], true, T).is_err(),
2681 "two points do not enclose anything"
2682 );
2683
2684 let mut doubled = square();
2686 doubled.insert(2, doubled[1]);
2687 assert!(make_polygon(&mut model, &doubled, true, T).is_err());
2688
2689 let mut wrapped = square();
2694 wrapped.push(wrapped[0]);
2695 for closed in [true, false] {
2696 let err = make_polygon(&mut model, &wrapped, closed, T).unwrap_err();
2697 assert!(
2698 err.to_string().contains("first and last points coincide"),
2699 "unexpected message: {err}"
2700 );
2701 }
2702 }
2703
2704 #[test]
2705 fn a_planar_shape_reports_the_plane_it_lies_in() {
2706 let mut model = Model::new();
2707 let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
2708 let plane = find_plane(&model, &wire, T)
2709 .unwrap()
2710 .expect("a flat square");
2711 assert!(
2712 plane.normal().is_parallel(ogeom_math::Direction::Z, T),
2713 "got {:?}",
2714 plane.normal()
2715 );
2716 for p in square() {
2717 assert!(plane.distance_to(p) < 1e-9);
2718 }
2719 }
2720
2721 #[test]
2722 fn a_shape_that_is_not_planar_says_so_rather_than_fitting_one_anyway() {
2723 let mut model = Model::new();
2726 let mut skew = square();
2727 skew[2] = Point::new(2.0, 2.0, 1.0);
2728 let wire = make_polygon(&mut model, &skew, true, T).unwrap().shape;
2729 assert!(find_plane(&model, &wire, T).unwrap().is_none());
2730 }
2731
2732 #[test]
2733 fn a_curved_edge_is_sampled_along_its_length_not_only_at_its_ends() {
2734 let mut model = Model::new();
2737 let circle = Circle::new(
2738 Frame::new(
2739 Point::ORIGIN,
2740 ogeom_math::Direction::Z,
2741 ogeom_math::Direction::X,
2742 T,
2743 )
2744 .unwrap(),
2745 2.0,
2746 T,
2747 )
2748 .unwrap();
2749 let arc = make_edge(
2750 &mut model,
2751 CircleCurve::new(circle).into(),
2752 (0.0, std::f64::consts::PI),
2753 T,
2754 )
2755 .unwrap()
2756 .shape;
2757 assert!(find_plane(&model, &arc, T).unwrap().is_some());
2759
2760 let plane = find_plane(&model, &arc, T).unwrap().unwrap();
2763 assert!(plane.normal().is_parallel(ogeom_math::Direction::Z, T));
2764 }
2765
2766 #[test]
2767 fn a_face_is_planar_when_its_surface_is() {
2768 let mut model = Model::new();
2769 let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
2770 let face = make_face(
2771 &mut model,
2772 PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
2773 std::slice::from_ref(&wire),
2774 T,
2775 )
2776 .unwrap()
2777 .shape;
2778 assert!(find_plane(&model, &face, T).unwrap().is_some());
2779
2780 let solid = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2781 .unwrap()
2782 .shape;
2783 assert!(
2784 find_plane(&model, &solid, T).unwrap().is_none(),
2785 "a box is not planar"
2786 );
2787 }
2788}
2789#[cfg(test)]
2790#[allow(clippy::unwrap_used, clippy::expect_used)]
2791mod compsolid_tests {
2792 use super::*;
2793 use ogeom_geom::PlaneSurface;
2794 use ogeom_math::{Frame, Plane, Point};
2795
2796 const T: Tolerances = Tolerances::millimetres();
2797
2798 fn glued_cubes(model: &mut Model) -> (Shape, Shape) {
2800 let a = crate::make_box(model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2801 .unwrap()
2802 .shape;
2803 let shared = ogeom_topo::explore(model, &a, ogeom_topo::Filter::OfType(ShapeType::Face))
2805 .unwrap()
2806 .into_iter()
2807 .find(|f| {
2808 ogeom_topo::explore(model, f, ogeom_topo::Filter::OfType(ShapeType::Vertex))
2809 .unwrap()
2810 .iter()
2811 .all(|v| {
2812 let p = model.node(v).unwrap().data().as_vertex().unwrap().point;
2813 let world = v.transform(model.datums()).unwrap().apply(p);
2814 (world.x - 1.0).abs() < 1e-9
2815 })
2816 })
2817 .expect("the box has a face at x = 1");
2818
2819 let corner = |x: f64, y: f64, z: f64| Point::new(x, y, z);
2822 let quad = |model: &mut Model, points: [Point; 4]| -> Shape {
2823 let wire = crate::make_polygon(model, &points, true, T).unwrap().shape;
2824 let plane = crate::find_plane(model, &wire, T).unwrap().unwrap();
2825 make_face(
2826 model,
2827 PlaneSurface::over(Plane::new(plane.frame()), (-4.0, 4.0), (-4.0, 4.0))
2828 .unwrap()
2829 .into(),
2830 std::slice::from_ref(&wire),
2831 T,
2832 )
2833 .unwrap()
2834 .shape
2835 };
2836 let faces = [
2837 quad(
2838 model,
2839 [
2840 corner(2.0, 0.0, 0.0),
2841 corner(2.0, 1.0, 0.0),
2842 corner(2.0, 1.0, 1.0),
2843 corner(2.0, 0.0, 1.0),
2844 ],
2845 ),
2846 quad(
2847 model,
2848 [
2849 corner(1.0, 0.0, 0.0),
2850 corner(2.0, 0.0, 0.0),
2851 corner(2.0, 0.0, 1.0),
2852 corner(1.0, 0.0, 1.0),
2853 ],
2854 ),
2855 quad(
2856 model,
2857 [
2858 corner(1.0, 1.0, 0.0),
2859 corner(1.0, 1.0, 1.0),
2860 corner(2.0, 1.0, 1.0),
2861 corner(2.0, 1.0, 0.0),
2862 ],
2863 ),
2864 quad(
2865 model,
2866 [
2867 corner(1.0, 0.0, 0.0),
2868 corner(1.0, 1.0, 0.0),
2869 corner(2.0, 1.0, 0.0),
2870 corner(2.0, 0.0, 0.0),
2871 ],
2872 ),
2873 quad(
2874 model,
2875 [
2876 corner(1.0, 0.0, 1.0),
2877 corner(2.0, 0.0, 1.0),
2878 corner(2.0, 1.0, 1.0),
2879 corner(1.0, 1.0, 1.0),
2880 ],
2881 ),
2882 ];
2883 let mut shell_faces = vec![shared.reversed()];
2884 shell_faces.extend(faces);
2885 let shell = make_shell(model, &shell_faces).unwrap().shape;
2886 let b = make_solid(model, std::slice::from_ref(&shell))
2887 .unwrap()
2888 .shape;
2889 (a, b)
2890 }
2891
2892 #[test]
2893 fn glued_solids_build_a_compsolid_and_loose_ones_are_refused() {
2894 let mut model = Model::new();
2895 let (a, b) = glued_cubes(&mut model);
2896 let built = make_compsolid(&mut model, &[a.clone(), b.clone()]).unwrap();
2897 assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::CompSolid);
2898 assert_eq!(
2899 built.history.generated(&a),
2900 std::slice::from_ref(&built.shape)
2901 );
2902
2903 let mut model = Model::new();
2905 let a = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2906 .unwrap()
2907 .shape;
2908 let far = Frame::new(
2909 Point::new(5.0, 0.0, 0.0),
2910 ogeom_math::Direction::Z,
2911 ogeom_math::Direction::X,
2912 T,
2913 )
2914 .unwrap();
2915 let b = crate::make_box(&mut model, far, (1.0, 1.0, 1.0), T)
2916 .unwrap()
2917 .shape;
2918 assert!(make_compsolid(&mut model, &[a.clone(), b]).is_err());
2919 assert!(make_compsolid(&mut model, std::slice::from_ref(&a)).is_err());
2920 }
2921}
2922
2923#[cfg(test)]
2924#[allow(clippy::unwrap_used, clippy::expect_used)]
2925mod band_tests {
2926 use super::*;
2927 use approx::assert_relative_eq;
2928 use ogeom_core::Tolerances;
2929 use ogeom_geom::CircleCurve;
2930 use ogeom_math::{Circle, Cone, Frame, Sphere};
2931
2932 const T: Tolerances = Tolerances::millimetres();
2933 const TAU: f64 = core::f64::consts::TAU;
2934
2935 fn mesh_area(model: &Model, face: &Shape) -> f64 {
2936 let fine = ogeom_mesh::Deflection {
2937 chord: 1e-3,
2938 ..ogeom_mesh::Deflection::default()
2939 };
2940 let mesh = ogeom_mesh::triangulate(model, face, fine, T).unwrap();
2941 mesh.triangles
2942 .iter()
2943 .map(|t| {
2944 let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
2945 (b - a).cross(c - a).magnitude() / 2.0
2946 })
2947 .sum()
2948 }
2949
2950 fn ring(model: &mut Model, frame: Frame, r: f64) -> Shape {
2952 let circle = Circle::new(frame, r, T).unwrap();
2953 let vertex = make_vertex(model, frame.origin() + frame.x() * r).shape;
2954 make_edge_between(
2955 model,
2956 CircleCurve::new(circle).into(),
2957 (0.0, TAU),
2958 &vertex,
2959 &vertex,
2960 T,
2961 )
2962 .unwrap()
2963 .shape
2964 }
2965
2966 #[test]
2967 fn a_cone_cap_takes_its_apex_as_a_degenerate_ring() {
2968 let mut model = Model::new();
2971 let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2972 let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
2973 .unwrap()
2974 .into();
2975 let rim = ring(&mut model, Frame::WORLD, 2.0);
2976 let face = crate::make_apex_band(&mut model, &surface, &rim, T).unwrap();
2977 assert_relative_eq!(
2978 mesh_area(&model, &face),
2979 core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
2980 max_relative = 1e-2
2981 );
2982 }
2983
2984 #[test]
2985 fn a_ring_square_to_the_axis_is_not_a_band_ring() {
2986 let mut model = Model::new();
2992 let sphere = ogeom_math::Sphere::centred(Point::ORIGIN, 5.0, T).unwrap();
2993 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
2994 let rim_at = |model: &mut Model, x: f64| {
2996 let frame = Frame::new(
2997 Point::new(x, 0.0, 0.0),
2998 ogeom_math::Direction::X,
2999 ogeom_math::Direction::Z,
3000 T,
3001 )
3002 .unwrap();
3003 ring(model, frame, (25.0 - x * x).sqrt())
3004 };
3005 let lo = rim_at(&mut model, 3.0);
3006 let hi = rim_at(&mut model, 4.0);
3007 assert!(!crate::rings_are_parallels(&model, &surface, &[&lo, &hi], T).unwrap());
3008 assert!(make_revolution_band(&mut model, &surface, &lo, &hi, T).is_err());
3009 let p_lo = ring(
3011 &mut model,
3012 Frame::new(
3013 Point::new(0.0, 0.0, 3.0),
3014 ogeom_math::Direction::Z,
3015 ogeom_math::Direction::X,
3016 T,
3017 )
3018 .unwrap(),
3019 4.0,
3020 );
3021 let p_hi = ring(
3022 &mut model,
3023 Frame::new(
3024 Point::new(0.0, 0.0, 4.0),
3025 ogeom_math::Direction::Z,
3026 ogeom_math::Direction::X,
3027 T,
3028 )
3029 .unwrap(),
3030 3.0,
3031 );
3032 assert!(crate::rings_are_parallels(&model, &surface, &[&p_lo, &p_hi], T).unwrap());
3033 }
3034
3035 #[test]
3036 fn a_degenerate_ring_is_accepted_whichever_side_it_is_passed_on() {
3037 let mut model = Model::new();
3038 let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
3039 let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
3040 .unwrap()
3041 .into();
3042 let rim = ring(&mut model, Frame::WORLD, 2.0);
3043 let apex = make_vertex(&mut model, cone.apex()).shape;
3044 let mut data = EdgeData::new();
3045 data.degenerate = true;
3046 let degenerate = model.add_edge(data, &[apex.clone(), apex]).unwrap();
3047 let face = make_revolution_band(&mut model, &surface, °enerate, &rim, T).unwrap();
3050 assert_relative_eq!(
3051 mesh_area(&model, &face),
3052 core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
3053 max_relative = 1e-2
3054 );
3055 }
3056
3057 #[test]
3058 fn a_sphere_cap_closes_against_its_pole() {
3059 let mut model = Model::new();
3061 let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
3062 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
3063 let rim = ring(&mut model, Frame::WORLD, 3.0);
3064 let pole = make_vertex(&mut model, Point::new(0.0, 0.0, 3.0)).shape;
3065 let mut data = EdgeData::new();
3066 data.degenerate = true;
3067 let degenerate = model.add_edge(data, &[pole.clone(), pole]).unwrap();
3068 let face = make_revolution_band(&mut model, &surface, &rim, °enerate, T).unwrap();
3069 assert_relative_eq!(
3070 mesh_area(&model, &face),
3071 2.0 * core::f64::consts::PI * 9.0,
3072 max_relative = 1e-2
3073 );
3074 }
3075
3076 #[test]
3077 fn two_degenerate_rings_bound_nothing_and_are_refused() {
3078 let mut model = Model::new();
3079 let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
3080 let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
3081 .unwrap()
3082 .into();
3083 let apex = make_vertex(&mut model, cone.apex()).shape;
3084 let mut data = EdgeData::new();
3085 data.degenerate = true;
3086 let a = model
3087 .add_edge(data.clone(), &[apex.clone(), apex.clone()])
3088 .unwrap();
3089 let b = model.add_edge(data, &[apex.clone(), apex]).unwrap();
3090 assert!(make_revolution_band(&mut model, &surface, &a, &b, T).is_err());
3091 }
3092
3093 #[test]
3094 fn an_apex_band_needs_a_cone() {
3095 let mut model = Model::new();
3096 let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
3097 let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
3098 let rim = ring(&mut model, Frame::WORLD, 3.0);
3099 assert!(crate::make_apex_band(&mut model, &surface, &rim, T).is_err());
3100 }
3101}