Skip to main content

ogeom_algo/
convert.rs

1//! Whole-shape conversion: everything as NURBS, and general affine
2//! transforms over a shape.
3//!
4//! The geometry-level conversions are exact and were done long ago; what a
5//! *shape* needs is the operator that walks it and restates every dependent
6//! description, because conversion necessarily reparameterizes: a circle's
7//! parameter is its angle and a rational quadratic's is not. So every edge's
8//! range moves to its converted curve's domain, and every pcurve is
9//! re-derived against a surface whose parameterization has also moved.
10//! Re-deriving a pcurve is a *fit* (at the edge's own parameters, so
11//! same-parameter holds by construction), which is why this waited for the
12//! adaptive fitting core.
13//!
14//! The affine operator rides on top: an affine map moves a B-spline's
15//! control points and nothing else, so the parameterizations of the
16//! converted shape survive the transform untouched, including every fitted
17//! pcurve, which is the point of converting first.
18
19use 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
30/// One converted edge: the shape, its curve and the curve's own range.
31type ConvertedEdge = (Shape, Curve, (f64, f64));
32
33/// A surface's chart window: the `u` then `v` interval.
34type ChartWindow = ((f64, f64), (f64, f64));
35
36/// Rebuild a solid with every surface and curve in B-spline form.
37///
38/// The result is a new solid in world coordinates (every occurrence
39/// placement baked in), whose geometry is exactly the original's wherever
40/// the conversions are exact (everywhere but the fitted pcurves, whose error
41/// is bounded by the fit target derived from the tolerance). History records
42/// each original face modified into its converted twin.
43///
44/// # Errors
45///
46/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
47/// shape is not a solid or a conversion has no exact form (a trimmed
48/// surface's basis is converted; nothing else refuses today);
49/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if a pcurve
50/// refit cannot reach its target.
51pub fn to_nurbs(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
52    rebuild(model, shape, None, Restate::Nurbs, tol)
53}
54
55/// Rebuild a solid with every surface and curve in B-spline form: exactly
56/// where a closed form exists, as [`to_nurbs`] does, and fitted within
57/// `tolerance` where none does (an offset surface, a helix, an offset
58/// curve), where [`to_nurbs`] refuses.
59///
60/// A fitted curve is same-parameter with the curve it replaces; a fitted
61/// surface has its own parameterization, and every trim on it is
62/// re-derived.
63///
64/// # Errors
65///
66/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
67/// `tolerance` is not a distance;
68/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if a fit cannot
69/// reach it; as [`to_nurbs`].
70pub 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
114/// Whether `new`'s normal points against `old`'s where they meet, read
115/// off the middle of `old`'s chart: what a restatement reports when it
116/// replaces a surface by one of the same points.
117///
118/// # Errors
119///
120/// As [`project_on_surface`](crate::project_on_surface), and if either
121/// surface has no normal there.
122pub fn normals_oppose(
123    old: &SurfaceGeometry,
124    new: &SurfaceGeometry,
125    tol: Tolerances,
126) -> OgeomResult<bool> {
127    let ((u0, u1), (v0, v1)) = old.domain();
128    // Off the exact middle: a revolution's middle can sit on its axis.
129    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
137/// Rebuild a solid with its placements baked into the geometry, keeping
138/// every surface and curve in its own analytic vocabulary.
139///
140/// A uniform-scale placement carries a cylinder to a cylinder and a line to
141/// a line (`Transformable` states each exactly), but the stored pcurves
142/// describe the *old* parameterizations. This rebuild restates the geometry
143/// in world space and re-derives every pcurve against it, exact where the
144/// chart alignment allows and projection-fitted with recorded slop where
145/// not, which is what lets a scaled shape enter the boolean's analytic
146/// pipeline instead of refusing.
147///
148/// # Errors
149///
150/// As [`to_nurbs`].
151pub fn baked_shape(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
152    rebuild(model, shape, None, Restate::Keep, tol)
153}
154
155/// Rebuild a solid under a general affine transform.
156///
157/// A shear or an uneven scale is not a placement: it carries a circle to an
158/// ellipse and a sphere to something with no analytic name here, so the
159/// shape is converted to its exact B-spline form and the *control points*
160/// are moved, which an affine map does exactly. The pcurves fitted during
161/// conversion survive untouched, because an affine map does not
162/// reparameterize.
163///
164/// A similarity is the other case, and it is a placement. The kernel's own
165/// types already draw that line: [`Transform`](ogeom_math::Transform) is a
166/// similarity by construction and is kept apart from [`GeneralTransform`]
167/// precisely so analytic geometry survives one, so a similarity handed in
168/// here is applied as the placement it is. Converting it would be worse than
169/// wasteful: a restated plane is still that plane but no longer *says* so,
170/// and coincidence downstream is decided on what the geometry says. Two
171/// coplanar faces would stop being coplanar *surfaces*, no closed form would
172/// answer `Same` for them, and the boolean would be handed a coincident pair
173/// with nothing to recognize it by.
174///
175/// # Errors
176///
177/// As [`to_nurbs`], or as [`transformed`](crate::transformed) for a similarity.
178pub 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
190/// A caller's restatement of a surface: the new surface and whether its
191/// normal points against the old one's, or `None` to keep it.
192pub type SurfaceRestatement<'a> =
193    &'a dyn Fn(&SurfaceGeometry) -> OgeomResult<Option<(SurfaceGeometry, bool)>>;
194
195/// A caller's restatement of a curve over a range: the new curve and the
196/// range on it tracing the same points, or `None` to keep it.
197pub type CurveRestatement<'a> =
198    &'a dyn Fn(&Curve, (f64, f64)) -> OgeomResult<Option<(Curve, (f64, f64))>>;
199
200/// Rebuild a solid with its surfaces and curves restated by the caller,
201/// every placement baked in and every pcurve re-derived against the new
202/// surfaces at the new edges' parameters.
203///
204/// `surface` is handed each face's surface, placed and bounded to the face;
205/// `curve` each edge's curve, placed, with the edge's range on it. Each
206/// answers `None` to keep what it was handed. A restatement must trace the
207/// same points: the rebuild re-derives trims, it does not move boundaries.
208///
209/// # Errors
210///
211/// As [`to_nurbs`], and whatever a restatement returns.
212pub 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/// How a rebuild restates the geometry it carries.
223#[derive(Clone, Copy)]
224enum Restate<'a> {
225    /// As it is, placed.
226    Keep,
227    /// As B-splines, exactly.
228    Nurbs,
229    /// As the caller says.
230    With(SurfaceRestatement<'a>, CurveRestatement<'a>),
231}
232
233/// The shared engine: convert, refit, optionally move control points.
234fn 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    // The fit target for re-derived pcurves: comfortably inside the model's
246    // own working band.
247    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            // Bounded to the face's own chart region first: a plane declares
273            // an enormous domain, and a patch over all of it would map the
274            // face to a dot in its chart.
275            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                        // A pole or an apex: still an edge in parameter
319                        // space; its pcurve is rebuilt below from the old
320                        // chart row's place in the new chart.
321                        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                    // The pcurve on this face: fitted at the new edge's own
383                    // parameters, seam sides each fitted against their own
384                    // half of the chart.
385                    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                        // The exact path: an edge that ran along the old
417                        // chart's iso direction runs along the new chart's,
418                        // and the boundary conversion shares the patch
419                        // direction's parameterization by construction.
420                        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                        // A closed edge (a rim) has one vertex at both
468                        // ends, and near a seam its single chart image is
469                        // one side's; pinning both ends there would fold the
470                        // ring. Its trace closes on its own.
471                        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                        // Exact wherever the chart has a closed form: the
482                        // melt downstream compares these images against
483                        // exact geometry, and a fitted stand-in for a
484                        // closed-form projection carries slop for nothing.
485                        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                                // The fit's honest slop rides the edge, so
503                                // every downstream filter widens by it,
504                                // and rides its vertices, which bound the
505                                // edge and cannot be held tighter than it.
506                                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            // The rebuilt chart is right-handed wherever the old one stood;
541            // a reflecting placement flipped the old chart's natural normal,
542            // and the flag must carry that flip or the baked solid comes
543            // out inside-out.
544            // A restated surface whose normal turned is the same flip.
545            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
561/// One edge converted: exact B-spline over its range, vertices carried.
562fn 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    // A placement with scale rescales a length-parameterized curve's domain
581    // (the same rule `transformed` itself applies), so the edge's range
582    // must move with it or the conversion covers only part of the edge.
583    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        // The affine map moves control points; the parameterization and the
594        // weights stay, which is the whole point of converting first.
595        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    // Vertices are shared across every edge that meets them: cached by the
613    // old node and the old vertex's own mapped point: the same bits every
614    // neighbouring edge computes, unlike each spline's evaluated end.
615    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        // The vertex's *own* composed placement, not the edge's: a prism's
627        // far cap references the profile's vertex nodes under the travel,
628        // and an edge placed identically still ends on a moved vertex.
629        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        // The old vertex's recorded slop (a file's, or a fit's) is the
635        // new one's too: the curves it meets are the same curves, moved
636        // exactly, and a vertex born at the default tolerance would refuse
637        // the edge its twin accepted.
638        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/// A pcurve refit at the edge's own parameters, optionally biased toward one
660/// side of the chart's `u` seam.
661#[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            // A point exactly on the seam projects to either side; the bias
683            // names which column this pcurve is.
684            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    // A closed patch is clamped, not periodic, but its geometry still closes:
692    // a trace crossing the closure jumps by the whole u span. Unwrapped, the
693    // fit sees the continuous curve the edge is; the pcurve may leave [u0,u1]
694    // by a little at the join, which evaluation tolerates.
695    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    // Ring corners are shared property: every edge meeting a vertex uses the
708    // vertex's own chart image, projected once per face, so the ring closes
709    // exactly instead of to each fit's own rounding.
710    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(&parameters, &trace, 3, target, tol)?;
721    Ok(fitted.curve.into())
722}
723
724/// An exact iso pcurve in the new chart, when the old one was an iso line.
725///
726/// Both endpoints project onto the patch; the pcurve is the straight chart
727/// segment between them, exact because the boundary conversion and the patch
728/// direction share one parameterization. `None` for anything that was not an
729/// axis-aligned chart line, which takes the fitted path.
730fn 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        // A row's v is one value; the endpoints agree up to projection noise,
760        // and a full-width row on a closed chart runs edge to edge, its
761        // seam-side endpoints snapped to the chart's own edges.
762        let v = f64::midpoint(a.y, b.y);
763        a.y = v;
764        b.y = v;
765        let span = nu1 - nu0;
766        // On the closure both chart edges image the same point, and the
767        // projection answers with either: a half circle from the seam
768        // round the back was drawn as the segment from the seam to the
769        // half-way column (the *front* half, mirrored), and the wall's
770        // ring lost its far side. The edge's own interior decides which
771        // column a seam endpoint is: the image of a point a little way in
772        // from that end lies on the same side of the chart.
773        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 closed row: the full width, oriented by the curve's start.
798            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
813/// Exact seam pcurves for a closed patch: the chart's two edges the seam
814/// runs along (its `u` columns or its `v` rows), each following the seam's
815/// projected course at the edge's own parameters. `None` where the seam
816/// does not lie on either pair of edges, or its course is not affine in
817/// its parameter.
818fn 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 &params {
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    // Which pair of chart edges the seam runs on, and the coordinate it
842    // runs along.
843    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
893/// The two seam-side pcurves of a closed patch: the chart's first and last
894/// `u` columns, each fitted at the edge's own parameters.
895fn 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
908/// The pole row of a degenerate edge, restated in the new chart.
909fn 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    // Which end of the old chart the pole row sat at decides which end of
919    // the new chart it sits at: conversion preserves ends.
920    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
938/// The surface shrunk to the face's own chart region, with a margin.
939///
940/// The face's pcurves say which part of the surface the face actually uses;
941/// converting the whole declared domain would spend the patch's parameter
942/// range on empty plane. Kinds whose windows are structural (a sphere's, a
943/// torus's, the closed direction of a cylinder) keep them.
944fn 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    // The window was read off the *old* pcurves, in the old chart's units;
991    // a placement with scale stretches the placed chart's length-like
992    // directions, and the trim must stretch with them. Angle directions
993    // never stretch.
994    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        // An offset over a plane's or drum's unbounded chart has no
1015        // extent to fit or convert over; held to the face's window, it has.
1016        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
1041/// A B-spline patch with its control points carried through an affine map.
1042fn 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
1058/// A rigid placement quantized for deduplication keys.
1059fn 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        // Every surface is now a spline.
1092        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        // The distinction this module is built on, stated as a test. A
1133        // similarity carries a plane to a plane, so the shape keeps saying
1134        // what it is; converting it would leave six patches that happen to be
1135        // flat, and nothing downstream that asks about coincidence can read
1136        // flatness out of a patch.
1137        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        // A unit-determinant shear: x' = x + 0.5 y.
1170        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        // An uneven stretch doubles x: volume doubles.
1181        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        // The case placements cannot express: the circular rims become
1195        // ellipses, and the volume is invariant under the unit-det shear.
1196        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}