1use crate::build::{attach_pcurve, attach_seam, make_edge_between, make_face_on};
20use crate::{Built, History, make_shell, make_solid, make_vertex, make_wire};
21use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
22use ogeom_geom::{Curve, Curve2d as _, Curve3d as _, Surface as _, SurfaceGeometry};
23use ogeom_math::{GeneralTransform, Point, Point2, Weighted};
24use ogeom_topo::{
25 EdgeRepr, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
26 explore_unique,
27};
28use std::collections::HashMap;
29
30type ConvertedEdge = (Shape, Curve, (f64, f64));
32
33type ChartWindow = ((f64, f64), (f64, f64));
35
36pub fn to_nurbs(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
52 rebuild(model, shape, None, Restate::Nurbs, tol)
53}
54
55pub fn to_nurbs_within(
71 model: &mut Model,
72 shape: &Shape,
73 tolerance: f64,
74 tol: Tolerances,
75) -> OgeomResult<Built> {
76 if !(tolerance.is_finite() && tolerance > 0.0) {
77 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
78 }
79 let surface = |s: &SurfaceGeometry| -> OgeomResult<Option<(SurfaceGeometry, bool)>> {
80 if let Ok(exact) = s.to_bspline(tol) {
81 return Ok(Some((exact.into(), false)));
82 }
83 let fitted = s.fitted_bspline(tolerance, tol)?;
84 if !fitted.met {
85 ogeom_bail!(
86 NotDone,
87 "a surface fitted as a spline stays {} away",
88 fitted.error
89 );
90 }
91 let fitted: SurfaceGeometry = fitted.curve.into();
92 let turned = normals_oppose(s, &fitted, tol)?;
93 Ok(Some((fitted, turned)))
94 };
95 let curve = |c: &Curve, range: (f64, f64)| -> OgeomResult<Option<(Curve, (f64, f64))>> {
96 if let Ok(exact) = c.to_bspline_over(range, tol) {
97 let exact: Curve = exact.into();
98 let domain = exact.domain();
99 return Ok(Some((exact, domain)));
100 }
101 let fitted = c.fitted_bspline_over(range, tolerance, tol)?;
102 if !fitted.met {
103 ogeom_bail!(
104 NotDone,
105 "a curve fitted as a spline stays {} away",
106 fitted.error
107 );
108 }
109 Ok(Some((fitted.curve.into(), range)))
110 };
111 rebuild(model, shape, None, Restate::With(&surface, &curve), tol)
112}
113
114pub fn normals_oppose(
123 old: &SurfaceGeometry,
124 new: &SurfaceGeometry,
125 tol: Tolerances,
126) -> OgeomResult<bool> {
127 let ((u0, u1), (v0, v1)) = old.domain();
128 let (u, v) = (u0 + (u1 - u0) * 0.43, v0 + (v1 - v0) * 0.57);
130 let p = old.point_at(u, v, tol)?;
131 let n_old = old.normal_at(u, v, tol)?;
132 let at = crate::measure::project_on_surface(new, p, 16, tol)?.parameters;
133 let n_new = new.normal_at(at.0, at.1, tol)?;
134 Ok(n_old.vector().dot(n_new.vector()) < 0.0)
135}
136
137pub fn baked_shape(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
152 rebuild(model, shape, None, Restate::Keep, tol)
153}
154
155pub fn general_transformed_shape(
179 model: &mut Model,
180 shape: &Shape,
181 transform: &GeneralTransform,
182 tol: Tolerances,
183) -> OgeomResult<Built> {
184 if let Some(similarity) = transform.to_similarity(tol.angular()) {
185 return crate::place::transformed(model, shape, similarity);
186 }
187 rebuild(model, shape, Some(transform), Restate::Nurbs, tol)
188}
189
190pub type SurfaceRestatement<'a> =
193 &'a dyn Fn(&SurfaceGeometry) -> OgeomResult<Option<(SurfaceGeometry, bool)>>;
194
195pub type CurveRestatement<'a> =
198 &'a dyn Fn(&Curve, (f64, f64)) -> OgeomResult<Option<(Curve, (f64, f64))>>;
199
200pub fn restate_geometry(
213 model: &mut Model,
214 shape: &Shape,
215 surface: SurfaceRestatement<'_>,
216 curve: CurveRestatement<'_>,
217 tol: Tolerances,
218) -> OgeomResult<Built> {
219 rebuild(model, shape, None, Restate::With(surface, curve), tol)
220}
221
222#[derive(Clone, Copy)]
224enum Restate<'a> {
225 Keep,
227 Nurbs,
229 With(SurfaceRestatement<'a>, CurveRestatement<'a>),
231}
232
233fn rebuild(
235 model: &mut Model,
236 shape: &Shape,
237 affine: Option<&GeneralTransform>,
238 restate: Restate<'_>,
239 tol: Tolerances,
240) -> OgeomResult<Built> {
241 if model.kind_of(shape)? != ShapeType::Solid {
242 ogeom_bail!(Construction, "whole-shape conversion rebuilds solids");
243 }
244 let map = |p: Point| affine.map_or(p, |t| t.apply(p));
245 let target = tol.confusion() * 1e2;
248
249 let mut history = History::new();
250 let mut new_vertices: HashMap<(TShapeId, [u64; 3]), Shape> = HashMap::new();
251 let mut new_edges: HashMap<(TShapeId, [u64; 3]), ConvertedEdge> = HashMap::new();
252 let mut shells = Vec::new();
253
254 for shell in explore_unique(model, shape, ShapeType::Shell)? {
255 let mut faces = Vec::new();
256 for face in model.ordered_children_of(&shell)? {
257 let placement = face.transform(model.datums())?;
258 let (surface_id_old, old_surface) = {
259 let Some(node) = model.node(&face) else {
260 ogeom_bail!(Dangling, "face is not in this model");
261 };
262 let NodeData::Face(data) = node.data() else {
263 ogeom_bail!(Construction, "face node holds no face data");
264 };
265 let Some(surface) = model.geometry().surface(data.surface) else {
266 ogeom_bail!(Dangling, "face refers to a surface not in this model");
267 };
268 (data.surface, surface.clone())
269 };
270 use ogeom_geom::Transformable as _;
271 let placed = old_surface.transformed(&placement, tol)?;
272 let (placed, old_window) = bounded_to_face(
276 model,
277 &face,
278 surface_id_old,
279 &placed,
280 placement.scale_factor().abs(),
281 tol,
282 )?;
283 let mut flipped = false;
284 let patch_surface: SurfaceGeometry = match restate {
285 Restate::Nurbs => {
286 let mut patch = placed.to_bspline(tol)?;
287 if let Some(t) = affine {
288 patch = transformed_patch(&patch, t)?;
289 }
290 patch.into()
291 }
292 Restate::Keep => placed.clone(),
293 Restate::With(surface, _) => match surface(&placed)? {
294 Some((restated, flip)) => {
295 flipped = flip;
296 restated
297 }
298 None => placed.clone(),
299 },
300 };
301 let surface_id = model.geometry_mut().add_surface(patch_surface.clone());
302
303 let mut wires = Vec::new();
304 let mut corner_uv: HashMap<TShapeId, Point2> = HashMap::new();
305 for wire in model.ordered_children_of(&face)? {
306 let mut ring = Vec::new();
307 let mut seams_done: Vec<TShapeId> = Vec::new();
308 for edge in model.ordered_children_of(&wire)? {
309 let edge_placement = edge.transform(model.datums())?;
310 let key = (edge.node(), placement_bits(&edge_placement));
311 let data = {
312 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
313 ogeom_bail!(Construction, "edge node holds no edge data");
314 };
315 data.clone()
316 };
317 if data.degenerate {
318 let Some(vertex) = model.children_of(&edge)?.first().cloned() else {
322 ogeom_bail!(Construction, "a degenerate edge has no vertex");
323 };
324 let at = {
325 let Some(v) = model.node(&vertex).and_then(|n| n.data().as_vertex())
326 else {
327 ogeom_bail!(Construction, "vertex node holds no vertex data");
328 };
329 map(vertex.transform(model.datums())?.apply(v.point))
330 };
331 let new_vertex = new_vertices
332 .entry((vertex.node(), point_bits(at)))
333 .or_insert_with(|| make_vertex(model, at).shape)
334 .clone();
335 let mut degenerate = ogeom_topo::EdgeData::new();
336 degenerate.degenerate = true;
337 let new_edge =
338 model.add_edge(degenerate, &[new_vertex.clone(), new_vertex])?;
339 let row = degenerate_row(
340 model,
341 &data,
342 surface_id_old,
343 &edge,
344 &placed,
345 &patch_surface,
346 tol,
347 )?;
348 attach_pcurve(
349 model,
350 &new_edge,
351 row.into(),
352 surface_id,
353 Location::identity(),
354 row.domain(),
355 )?;
356 ring.push(if edge.orientation() == Orientation::Reversed {
357 new_edge.reversed()
358 } else {
359 new_edge
360 });
361 continue;
362 }
363
364 let (new_edge, new_curve, new_range) = match new_edges.get(&key) {
365 Some(found) => found.clone(),
366 None => {
367 let built = convert_edge(
368 model,
369 &edge,
370 &data,
371 &map,
372 restate,
373 &mut new_vertices,
374 tol,
375 )?;
376 history.modify(&edge, built.0.clone());
377 new_edges.insert(key, built.clone());
378 built
379 }
380 };
381
382 let old_repr = data.pcurve_for(surface_id_old, edge.location()).cloned();
386 let was_seam = matches!(old_repr, Some(EdgeRepr::Seam { .. }));
387 if was_seam {
388 if !seams_done.contains(&new_edge.node()) {
389 seams_done.push(new_edge.node());
390 let (forward, reversed) =
391 exact_seam_columns(&new_curve, new_range, &patch_surface, tol)
392 .map_or_else(
393 || {
394 seam_pcurves(
395 &new_curve,
396 new_range,
397 &patch_surface,
398 target,
399 tol,
400 )
401 },
402 Ok,
403 )?;
404 let seam_range = forward.domain();
405 attach_seam(
406 model,
407 &new_edge,
408 forward,
409 reversed,
410 surface_id,
411 Location::identity(),
412 seam_range,
413 )?;
414 }
415 } else {
416 if let Some(iso) = exact_iso_pcurve(
421 model,
422 old_repr.as_ref(),
423 old_window,
424 &new_curve,
425 new_range,
426 &patch_surface,
427 tol,
428 )? {
429 let iso_range = iso.domain();
430 attach_pcurve(
431 model,
432 &new_edge,
433 iso,
434 surface_id,
435 Location::identity(),
436 iso_range,
437 )?;
438 ring.push(if edge.orientation() == Orientation::Reversed {
439 new_edge.reversed()
440 } else {
441 new_edge
442 });
443 continue;
444 }
445 let uv_of = |model: &Model,
446 cache: &mut HashMap<TShapeId, Point2>,
447 vertex: &Shape|
448 -> OgeomResult<Point2> {
449 if let Some(&uv) = cache.get(&vertex.node()) {
450 return Ok(uv);
451 }
452 let Some(v) = model.node(vertex).and_then(|n| n.data().as_vertex())
453 else {
454 ogeom_bail!(Construction, "vertex node holds no vertex data");
455 };
456 let projection = crate::measure::project_on_surface(
457 &patch_surface,
458 v.point,
459 24,
460 tol,
461 )?;
462 let uv = Point2::new(projection.parameters.0, projection.parameters.1);
463 cache.insert(vertex.node(), uv);
464 Ok(uv)
465 };
466 let bounds = model.children_of(&new_edge)?;
467 let closed_edge =
472 bounds.len() < 2 || bounds[0].node() == bounds[bounds.len() - 1].node();
473 let ends = if closed_edge {
474 (None, None)
475 } else {
476 (
477 Some(uv_of(model, &mut corner_uv, &bounds[0])?),
478 Some(uv_of(model, &mut corner_uv, &bounds[bounds.len() - 1])?),
479 )
480 };
481 let derived = match ogeom_intersect::exact_pcurve_over(
486 &new_curve,
487 new_range,
488 &patch_surface,
489 tol,
490 ) {
491 Some(exact) => exact,
492 None => {
493 let fitted = fit_pcurve(
494 &new_curve,
495 new_range,
496 &patch_surface,
497 None,
498 ends,
499 target,
500 tol,
501 )?;
502 let widened = if let Some(node) = model.node_mut(&new_edge)
507 && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
508 {
509 data.tolerance = data.tolerance.widen_to(target);
510 Some(data.tolerance)
511 } else {
512 None
513 };
514 if let Some(widened) = widened {
515 for vertex in model.ordered_children_of(&new_edge)? {
516 model.widen(&vertex, widened)?;
517 }
518 }
519 fitted
520 }
521 };
522 attach_pcurve(
523 model,
524 &new_edge,
525 derived,
526 surface_id,
527 Location::identity(),
528 new_range,
529 )?;
530 }
531 ring.push(if edge.orientation() == Orientation::Reversed {
532 new_edge.reversed()
533 } else {
534 new_edge
535 });
536 }
537 wires.push(make_wire(model, &ring, tol)?.shape);
538 }
539 let built = make_face_on(model, surface_id, &wires, tol)?.shape;
540 let reflected = !face.location().preserves_handedness(model.datums())? != flipped;
546 let built = if (face.orientation() == Orientation::Reversed) != reflected {
547 built.reversed()
548 } else {
549 built
550 };
551 history.modify(&face, built.clone());
552 faces.push(built);
553 }
554 shells.push(make_shell(model, &faces)?.shape);
555 }
556 let solid = make_solid(model, &shells)?.shape;
557 history.modify(shape, solid.clone());
558 Ok(Built::new(solid, history))
559}
560
561fn convert_edge(
563 model: &mut Model,
564 edge: &Shape,
565 data: &ogeom_topo::EdgeData,
566 map: &dyn Fn(Point) -> Point,
567 restate: Restate<'_>,
568 vertices: &mut HashMap<(TShapeId, [u64; 3]), Shape>,
569 tol: Tolerances,
570) -> OgeomResult<(Shape, Curve, (f64, f64))> {
571 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
572 ogeom_bail!(Construction, "an edge with no curve cannot be converted");
573 };
574 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
575 ogeom_bail!(Dangling, "curve is not in this model");
576 };
577 let placement = edge.transform(model.datums())?;
578 use ogeom_geom::Transformable as _;
579 let placed = geometry.transformed(&placement, tol)?;
580 let stretch = placement.scale_factor().abs();
584 let range_on_placed = match &geometry {
585 Curve::Line(_) => (range.0 * stretch, range.1 * stretch),
586 Curve::Trimmed(t) if matches!(t.basis(), Curve::Line(_)) => {
587 (range.0 * stretch, range.1 * stretch)
588 }
589 _ => *range,
590 };
591 let (curve, new_range): (Curve, (f64, f64)) = if matches!(restate, Restate::Nurbs) {
592 let spline = placed.to_bspline_over(range_on_placed, tol)?;
593 let weighted: Vec<Weighted<Point>> = spline
596 .control_points()
597 .iter()
598 .map(|c| {
599 let p = Point::from_vector(c.scaled.to_vector() / c.weight);
600 Weighted::new(map(p), c.weight, tol)
601 })
602 .collect::<OgeomResult<_>>()?;
603 let spline = ogeom_geom::BSplineCurve::rational(spline.knots().clone(), weighted)?;
604 let curve: Curve = spline.into();
605 let range = curve.domain();
606 (curve, range)
607 } else if let Restate::With(_, restated) = restate {
608 restated(&placed, range_on_placed)?.unwrap_or((placed, range_on_placed))
609 } else {
610 (placed, range_on_placed)
611 };
612 let old = model.children_of(edge)?;
616 if old.is_empty() {
617 ogeom_bail!(Construction, "an edge with no vertices cannot be converted");
618 }
619 let mut resolve = |model: &mut Model, occurrence: &Shape| -> OgeomResult<Shape> {
620 let Some(data) = model
621 .node(occurrence)
622 .and_then(|n| n.data().as_vertex().cloned())
623 else {
624 ogeom_bail!(Construction, "vertex node holds no vertex data");
625 };
626 let at = map(occurrence.transform(model.datums())?.apply(data.point));
630 let fresh = vertices
631 .entry((occurrence.node(), point_bits(at)))
632 .or_insert_with(|| make_vertex(model, at).shape)
633 .clone();
634 if data.tolerance.get() > tol.confusion() {
639 model.widen(&fresh, data.tolerance)?;
640 }
641 Ok(fresh)
642 };
643 let va = resolve(model, &old[0])?;
644 let vb = if old.len() == 1 {
645 va.clone()
646 } else {
647 resolve(model, &old[old.len() - 1])?
648 };
649 let built = make_edge_between(model, curve.clone(), new_range, &va, &vb, tol)?.shape;
650 if data.tolerance.get() > tol.confusion()
651 && let Some(node) = model.node_mut(&built)
652 && let NodeData::Edge(fresh) = node.data_mut()
653 {
654 fresh.tolerance = fresh.tolerance.widen_to(data.tolerance.get());
655 }
656 Ok((built, curve, new_range))
657}
658
659#[allow(clippy::too_many_arguments)]
662fn fit_pcurve(
663 curve: &Curve,
664 range: (f64, f64),
665 surface: &SurfaceGeometry,
666 side: Option<f64>,
667 ends: (Option<Point2>, Option<Point2>),
668 target: f64,
669 tol: Tolerances,
670) -> OgeomResult<ogeom_geom::PlanarCurve> {
671 const SAMPLES: usize = 48;
672 let ((u0, u1), _) = surface.domain();
673 let mut parameters = Vec::with_capacity(SAMPLES + 1);
674 let mut trace = Vec::with_capacity(SAMPLES + 1);
675 for i in 0..=SAMPLES {
676 #[allow(clippy::cast_precision_loss)]
677 let t = range.0 + (range.1 - range.0) * i as f64 / SAMPLES as f64;
678 let p = curve.point_at(t, tol)?;
679 let projection = crate::measure::project_on_surface(surface, p, 24, tol)?;
680 let (mut u, v) = projection.parameters;
681 if let Some(bias) = side {
682 if (u - u0).abs() < (u1 - u0) * 0.25 || (u - u1).abs() < (u1 - u0) * 0.25 {
685 u = bias;
686 }
687 }
688 parameters.push(t);
689 trace.push(Point2::new(u, v));
690 }
691 let span = u1 - u0;
696 for i in 1..trace.len() {
697 let mut du = trace[i].x - trace[i - 1].x;
698 while du > span * 0.5 {
699 trace[i].x -= span;
700 du = trace[i].x - trace[i - 1].x;
701 }
702 while du < -span * 0.5 {
703 trace[i].x += span;
704 du = trace[i].x - trace[i - 1].x;
705 }
706 }
707 if let Some(start) = ends.0
711 && let Some(first) = trace.first_mut()
712 {
713 *first = start;
714 }
715 if let Some(end) = ends.1
716 && let Some(last) = trace.last_mut()
717 {
718 *last = end;
719 }
720 let fitted = ogeom_geom::fit::fit_points_2d_at(¶meters, &trace, 3, target, tol)?;
721 Ok(fitted.curve.into())
722}
723
724fn exact_iso_pcurve(
731 model: &Model,
732 old_repr: Option<&EdgeRepr>,
733 _old_window: ChartWindow,
734 curve: &Curve,
735 range: (f64, f64),
736 surface: &SurfaceGeometry,
737 tol: Tolerances,
738) -> OgeomResult<Option<ogeom_geom::PlanarCurve>> {
739 let Some(EdgeRepr::PCurve { curve: old_pc, .. }) = old_repr else {
740 return Ok(None);
741 };
742 let Some(ogeom_geom::PlanarCurve::Line(old_line)) = model.geometry().pcurve(*old_pc) else {
743 return Ok(None);
744 };
745 let d = old_line.axis().direction;
746 let axis_aligned = d.vector().x.abs() <= 1e-12 || d.vector().y.abs() <= 1e-12;
747 if !axis_aligned {
748 return Ok(None);
749 }
750 let ((nu0, nu1), (nv0, nv1)) = surface.domain();
751 let start = curve.point_at(range.0, tol)?;
752 let end = curve.point_at(range.1, tol)?;
753 let a = crate::measure::project_on_surface(surface, start, 24, tol)?;
754 let b = crate::measure::project_on_surface(surface, end, 24, tol)?;
755 let mut a = Point2::new(a.parameters.0, a.parameters.1);
756 let mut b = Point2::new(b.parameters.0, b.parameters.1);
757 let row = d.vector().y.abs() <= 1e-12;
758 if row {
759 let v = f64::midpoint(a.y, b.y);
763 a.y = v;
764 b.y = v;
765 let span = nu1 - nu0;
766 let at_seam = |u: &f64| (*u - nu0).abs() < span * 1e-6 || (*u - nu1).abs() < span * 1e-6;
774 let inside = |t: f64| -> OgeomResult<f64> {
775 let p = curve.point_at(t, tol)?;
776 Ok(crate::measure::project_on_surface(surface, p, 24, tol)?
777 .parameters
778 .0)
779 };
780 let middle = f64::midpoint(nu0, nu1);
781 let step = (range.1 - range.0) * 0.02;
782 if at_seam(&a.x) {
783 a.x = if inside(range.0 + step)? < middle {
784 nu0
785 } else {
786 nu1
787 };
788 }
789 if at_seam(&b.x) {
790 b.x = if inside(range.1 - step)? < middle {
791 nu0
792 } else {
793 nu1
794 };
795 }
796 if (a.x - b.x).abs() < span * 1e-9 {
797 a.x = nu0;
799 b.x = nu1;
800 }
801 } else {
802 let u = f64::midpoint(a.x, b.x);
803 a.x = u;
804 b.x = u;
805 if (a.y - b.y).abs() < (nv1 - nv0) * 1e-9 {
806 a.y = nv0;
807 b.y = nv1;
808 }
809 }
810 Ok(Some(ogeom_geom::Line2d::segment(a, b, tol)?.into()))
811}
812
813fn exact_seam_columns(
819 curve: &Curve,
820 range: (f64, f64),
821 surface: &SurfaceGeometry,
822 tol: Tolerances,
823) -> Option<(ogeom_geom::PlanarCurve, ogeom_geom::PlanarCurve)> {
824 use ogeom_geom::Surface as _;
825 let ((u0, u1), (v0, v1)) = surface.domain();
826 const N: usize = 8;
827 #[allow(clippy::cast_precision_loss)]
828 let params: Vec<f64> = (0..=N)
829 .map(|i| range.0 + (range.1 - range.0) * i as f64 / N as f64)
830 .collect();
831 let mut chart: Vec<Point2> = Vec::with_capacity(N + 1);
832 for &t in ¶ms {
833 let p = curve.point_at(t, tol).ok()?;
834 let at = crate::measure::project_on_surface(surface, p, 24, tol).ok()?;
835 chart.push(Point2::new(at.parameters.0, at.parameters.1));
836 }
837 let on = |x: f64, a: f64, b: f64| {
838 let eps = (b - a).abs() * 1e-6;
839 (x - a).abs() <= eps || (x - b).abs() <= eps
840 };
841 let (columns, lo, hi, period) = if chart.iter().all(|p| on(p.x, u0, u1)) {
844 (true, u0, u1, surface.is_periodic_v().then_some(v1 - v0))
845 } else if chart.iter().all(|p| on(p.y, v0, v1)) {
846 (false, v0, v1, surface.is_periodic_u().then_some(u1 - u0))
847 } else {
848 return None;
849 };
850 let mut run: Vec<f64> = chart
851 .iter()
852 .map(|p| if columns { p.y } else { p.x })
853 .collect();
854 if let Some(period) = period {
855 for i in 1..run.len() {
856 while run[i] - run[i - 1] > period * 0.5 {
857 run[i] -= period;
858 }
859 while run[i] - run[i - 1] < -period * 0.5 {
860 run[i] += period;
861 }
862 }
863 }
864 let slope = (run[N] - run[0]) / (range.1 - range.0);
865 let span = (run[N] - run[0]).abs();
866 if span <= tol.parametric() {
867 return None;
868 }
869 let affine = params
870 .iter()
871 .zip(&run)
872 .all(|(t, r)| (run[0] + slope * (t - range.0) - r).abs() <= span * 1e-7);
873 if !affine {
874 return None;
875 }
876 let side = |at: f64| -> Option<ogeom_geom::PlanarCurve> {
877 let (a, b) = if columns {
878 (Point2::new(at, run[0]), Point2::new(at, run[N]))
879 } else {
880 (Point2::new(run[0], at), Point2::new(run[N], at))
881 };
882 let knots =
883 ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1).ok()?;
884 Some(
885 ogeom_geom::BSpline2d::new(knots, vec![a, b], tol)
886 .ok()?
887 .into(),
888 )
889 };
890 Some((side(hi)?, side(lo)?))
891}
892
893fn seam_pcurves(
896 curve: &Curve,
897 range: (f64, f64),
898 surface: &SurfaceGeometry,
899 target: f64,
900 tol: Tolerances,
901) -> OgeomResult<(ogeom_geom::PlanarCurve, ogeom_geom::PlanarCurve)> {
902 let ((u0, u1), _) = surface.domain();
903 let forward = fit_pcurve(curve, range, surface, Some(u1), (None, None), target, tol)?;
904 let reversed = fit_pcurve(curve, range, surface, Some(u0), (None, None), target, tol)?;
905 Ok((forward, reversed))
906}
907
908fn degenerate_row(
910 model: &Model,
911 data: &ogeom_topo::EdgeData,
912 old_surface: ogeom_topo::SurfaceId,
913 edge: &Shape,
914 old: &SurfaceGeometry,
915 new: &SurfaceGeometry,
916 tol: Tolerances,
917) -> OgeomResult<ogeom_geom::Line2d> {
918 let Some(EdgeRepr::PCurve { curve, .. }) = data.pcurve_for(old_surface, edge.location()) else {
921 ogeom_bail!(Construction, "a degenerate edge has no pcurve to restate");
922 };
923 let Some(row) = model.geometry().pcurve(*curve) else {
924 ogeom_bail!(Dangling, "pcurve is not in this model");
925 };
926 let (lo, hi) = row.domain();
927 let v_old = f64::midpoint(row.point_at(lo, tol)?.y, row.point_at(hi, tol)?.y);
928 let (_, (va_old, vb_old)) = old.domain();
929 let ((nu0, nu1), (nv0, nv1)) = new.domain();
930 let v_new = if (v_old - va_old).abs() <= (v_old - vb_old).abs() {
931 nv0
932 } else {
933 nv1
934 };
935 ogeom_geom::Line2d::segment(Point2::new(nu0, v_new), Point2::new(nu1, v_new), tol)
936}
937
938fn bounded_to_face(
945 model: &Model,
946 face: &Shape,
947 surface_id: ogeom_topo::SurfaceId,
948 placed: &SurfaceGeometry,
949 stretch: f64,
950 tol: Tolerances,
951) -> OgeomResult<(SurfaceGeometry, ChartWindow)> {
952 let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
953 let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
954 for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
955 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
956 continue;
957 };
958 let mut widen = |pc: ogeom_topo::PCurveId, range: (f64, f64)| -> OgeomResult<()> {
959 let Some(pcurve) = model.geometry().pcurve(pc) else {
960 return Ok(());
961 };
962 for i in 0..=8 {
963 #[allow(clippy::cast_precision_loss)]
964 let t = range.0 + (range.1 - range.0) * f64::from(i) / 8.0;
965 let p = pcurve.point_at(t, tol)?;
966 u0 = u0.min(p.x);
967 u1 = u1.max(p.x);
968 v0 = v0.min(p.y);
969 v1 = v1.max(p.y);
970 }
971 Ok(())
972 };
973 match data.pcurve_for(surface_id, edge.location()) {
974 Some(EdgeRepr::PCurve { curve, range, .. }) => widen(*curve, *range)?,
975 Some(EdgeRepr::Seam {
976 forward,
977 reversed,
978 range,
979 ..
980 }) => {
981 widen(*forward, *range)?;
982 widen(*reversed, *range)?;
983 }
984 _ => {}
985 }
986 }
987 if !u0.is_finite() || u1 - u0 <= tol.confusion() || v1 - v0 <= tol.confusion() {
988 return Ok((placed.clone(), placed.domain()));
989 }
990 let (su, sv) = match placed {
995 SurfaceGeometry::Plane(_) => (stretch, stretch),
996 SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_) => (1.0, stretch),
997 _ => (1.0, 1.0),
998 };
999 let (u0, u1, v0, v1) = (u0 * su, u1 * su, v0 * sv, v1 * sv);
1000 let margin = ((u1 - u0) + (v1 - v0)) * 0.05 + tol.confusion();
1001 let bounded: SurfaceGeometry = match placed {
1002 SurfaceGeometry::Plane(p) => ogeom_geom::PlaneSurface::over(
1003 p.plane(),
1004 (u0 - margin, u1 + margin),
1005 (v0 - margin, v1 + margin),
1006 )?
1007 .into(),
1008 SurfaceGeometry::Cylinder(c) => {
1009 ogeom_geom::CylinderSurface::new(c.cylinder(), (v0 - margin, v1 + margin))?.into()
1010 }
1011 SurfaceGeometry::Cone(c) => {
1012 ogeom_geom::ConeSurface::new(c.cone(), (v0 - margin, v1 + margin))?.into()
1013 }
1014 SurfaceGeometry::Offset(o) if !finite(o.basis().domain()) => {
1017 let ((du0, du1), (dv0, dv1)) = placed.domain();
1018 ogeom_geom::TrimmedSurface::new(
1019 placed.clone(),
1020 ((u0 - margin).max(du0), (u1 + margin).min(du1)),
1021 ((v0 - margin).max(dv0), (v1 + margin).min(dv1)),
1022 tol,
1023 )
1024 .map_or_else(
1025 |_| placed.clone(),
1026 |t| SurfaceGeometry::Trimmed(Box::new(t)),
1027 )
1028 }
1029 other => other.clone(),
1030 };
1031 let window = bounded.domain();
1032 Ok((bounded, window))
1033}
1034
1035fn finite(domain: ChartWindow) -> bool {
1036 [domain.0.0, domain.0.1, domain.1.0, domain.1.1]
1037 .iter()
1038 .all(|x| x.is_finite() && x.abs() < 1e6)
1039}
1040
1041fn transformed_patch(
1043 patch: &ogeom_geom::BSplineSurface,
1044 transform: &GeneralTransform,
1045) -> OgeomResult<ogeom_geom::BSplineSurface> {
1046 let grid = patch.grid();
1047 let mapped = grid.map(|c| {
1048 let p = Point::from_vector(c.scaled.to_vector() / c.weight);
1049 let moved = transform.apply(p);
1050 Weighted {
1051 scaled: Point::from_vector(moved.to_vector() * c.weight),
1052 weight: c.weight,
1053 }
1054 });
1055 ogeom_geom::BSplineSurface::rational(patch.u_knots().clone(), patch.v_knots().clone(), mapped)
1056}
1057
1058fn placement_bits(t: &ogeom_math::Transform) -> [u64; 3] {
1060 let p = t.apply(Point::new(0.123_456_789, 9.87, -3.21));
1061 point_bits(p)
1062}
1063
1064fn point_bits(p: Point) -> [u64; 3] {
1065 [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
1066}
1067
1068#[cfg(test)]
1069#[allow(clippy::unwrap_used, clippy::expect_used)]
1070mod tests {
1071 use super::*;
1072 use crate::{make_box, make_cylinder, volume_properties};
1073 use ogeom_math::Frame;
1074 use ogeom_mesh::Deflection;
1075
1076 const T: Tolerances = Tolerances::millimetres();
1077
1078 fn fine() -> Deflection {
1079 Deflection {
1080 chord: 1e-3,
1081 ..Deflection::default()
1082 }
1083 }
1084
1085 #[test]
1086 fn a_box_converts_to_nurbs_and_measures_the_same() {
1087 let mut model = Model::new();
1088 let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1089 let converted = to_nurbs(&mut model, &solid.shape, T).unwrap();
1090
1091 for face in explore(&model, &converted.shape, Filter::OfType(ShapeType::Face)).unwrap() {
1093 let NodeData::Face(data) = model.node(&face).unwrap().data() else {
1094 panic!("face data");
1095 };
1096 assert!(matches!(
1097 model.geometry().surface(data.surface),
1098 Some(SurfaceGeometry::BSpline(_))
1099 ));
1100 }
1101 let props = volume_properties(&model, &converted.shape, fine(), T).unwrap();
1102 assert!((props.mass - 24.0).abs() < 1e-6, "volume {}", props.mass);
1103 assert!(
1104 crate::check(&model, &converted.shape, T)
1105 .unwrap()
1106 .is_valid(),
1107 "{}",
1108 crate::check(&model, &converted.shape, T).unwrap()
1109 );
1110 assert_eq!(
1111 converted.history.modified(&solid.shape),
1112 std::slice::from_ref(&converted.shape)
1113 );
1114 }
1115
1116 #[test]
1117 fn a_cylinder_converts_seams_poles_and_all() {
1118 let mut model = Model::new();
1119 let solid = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
1120 let converted = to_nurbs(&mut model, &solid.shape, T).unwrap();
1121 let exact = core::f64::consts::PI * 4.0 * 5.0;
1122 let props = volume_properties(&model, &converted.shape, fine(), T).unwrap();
1123 assert!(
1124 (props.mass - exact).abs() < exact * 5e-3,
1125 "volume {} against {exact}",
1126 props.mass
1127 );
1128 }
1129
1130 #[test]
1131 fn a_uniform_scale_is_a_placement_and_leaves_the_surfaces_analytic() {
1132 let mut model = Model::new();
1138 let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1139 let scale = GeneralTransform::new(
1140 ogeom_math::Matrix3 {
1141 rows: [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]],
1142 },
1143 ogeom_math::Vector::ZERO,
1144 );
1145
1146 let scaled = general_transformed_shape(&mut model, &solid.shape, &scale, T).unwrap();
1147
1148 for face in explore(&model, &scaled.shape, Filter::OfType(ShapeType::Face)).unwrap() {
1149 let NodeData::Face(data) = model.node(&face).unwrap().data() else {
1150 panic!("face data");
1151 };
1152 assert!(
1153 matches!(
1154 model.geometry().surface(data.surface),
1155 Some(SurfaceGeometry::Plane(_))
1156 ),
1157 "a scaled box's face stopped being a plane"
1158 );
1159 }
1160 let props = volume_properties(&model, &scaled.shape, fine(), T).unwrap();
1161 assert!((props.mass - 192.0).abs() < 1e-6, "scaled {}", props.mass);
1162 }
1163
1164 #[test]
1165 fn a_shear_preserves_volume_and_a_stretch_scales_it() {
1166 let mut model = Model::new();
1167 let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1168
1169 let shear = GeneralTransform::new(
1171 ogeom_math::Matrix3 {
1172 rows: [[1.0, 0.5, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1173 },
1174 ogeom_math::Vector::ZERO,
1175 );
1176 let sheared = general_transformed_shape(&mut model, &solid.shape, &shear, T).unwrap();
1177 let props = volume_properties(&model, &sheared.shape, fine(), T).unwrap();
1178 assert!((props.mass - 24.0).abs() < 1e-6, "sheared {}", props.mass);
1179
1180 let stretch = GeneralTransform::new(
1182 ogeom_math::Matrix3 {
1183 rows: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1184 },
1185 ogeom_math::Vector::ZERO,
1186 );
1187 let stretched = general_transformed_shape(&mut model, &solid.shape, &stretch, T).unwrap();
1188 let props = volume_properties(&model, &stretched.shape, fine(), T).unwrap();
1189 assert!((props.mass - 48.0).abs() < 1e-6, "stretched {}", props.mass);
1190 }
1191
1192 #[test]
1193 fn a_sheared_cylinder_still_encloses_its_volume() {
1194 let mut model = Model::new();
1197 let solid = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
1198 let shear = GeneralTransform::new(
1199 ogeom_math::Matrix3 {
1200 rows: [[1.0, 0.0, 0.4], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1201 },
1202 ogeom_math::Vector::ZERO,
1203 );
1204 let sheared = general_transformed_shape(&mut model, &solid.shape, &shear, T).unwrap();
1205 let exact = core::f64::consts::PI * 4.0 * 5.0;
1206 let props = volume_properties(&model, &sheared.shape, fine(), T).unwrap();
1207 assert!(
1208 (props.mass - exact).abs() < exact * 5e-3,
1209 "sheared cylinder {} against {exact}",
1210 props.mass
1211 );
1212 }
1213}