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. What a *shape* needs is the
5//! operator that walks it and restates every dependent description, because
6//! conversion necessarily reparameterizes: a circle's parameter is its angle
7//! and a rational quadratic's is not. So every edge's range moves to its
8//! converted curve's domain, and every pcurve is re-derived against a
9//! surface whose parameterization has also moved. Re-deriving a pcurve is a
10//! *fit*, at the edge's own parameters, so same-parameter holds by
11//! construction.
12//!
13//! The affine operator rides on top: an affine map moves a B-spline's
14//! control points and nothing else, so the parameterizations of the
15//! converted shape survive the transform untouched, including every fitted
16//! pcurve, which is the point of converting first.
17
18use crate::build::{attach_pcurve, attach_seam, make_edge_between, make_face_on};
19use crate::{Built, History, make_compound, make_shell, make_solid, make_vertex, make_wire};
20use ogeom_core::FastMap;
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};
27
28/// One converted edge: the shape, its curve and the curve's own range.
29type ConvertedEdge = (Shape, Curve, (f64, f64));
30
31/// A surface's chart window: the `u` then `v` interval.
32type ChartWindow = ((f64, f64), (f64, f64));
33
34/// Rebuild a solid, a shell, a face, or a compound of them, with every
35/// surface and curve in B-spline form.
36///
37/// The result is a new shape of the same kind in world coordinates (every
38/// occurrence placement baked in), whose geometry is exactly the
39/// original's wherever the conversions are exact (everywhere but the
40/// fitted pcurves, whose error is bounded by the fit target derived from
41/// the tolerance). History records each original face, edge occurrence and
42/// container modified into its converted twin.
43///
44/// # Errors
45///
46/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
47/// shape is not a solid, a shell, a face or a compound of them, or a
48/// conversion has no exact form (a trimmed
49/// surface's basis is converted; nothing else refuses);
50/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if a pcurve
51/// refit cannot reach its target.
52pub fn to_nurbs(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
53    rebuild(model, shape, None, Restate::Nurbs, tol)
54}
55
56/// Rebuild a shape with every surface and curve in B-spline form: exactly
57/// where a closed form exists, as [`to_nurbs`] does, and fitted within
58/// `tolerance` where none does (an offset surface, a helix, an offset
59/// curve), where [`to_nurbs`] refuses.
60///
61/// A fitted curve is same-parameter with the curve it replaces; a fitted
62/// surface has its own parameterization, and every trim on it is
63/// re-derived.
64///
65/// # Errors
66///
67/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
68/// `tolerance` is not a distance;
69/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if a fit cannot
70/// reach it; as [`to_nurbs`].
71pub fn to_nurbs_within(
72    model: &mut Model,
73    shape: &Shape,
74    tolerance: f64,
75    tol: Tolerances,
76) -> OgeomResult<Built> {
77    if !(tolerance.is_finite() && tolerance > 0.0) {
78        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
79    }
80    let surface = |s: &SurfaceGeometry| -> OgeomResult<Option<(SurfaceGeometry, bool)>> {
81        if let Ok(exact) = s.to_bspline(tol) {
82            return Ok(Some((exact.into(), false)));
83        }
84        let fitted = s.fitted_bspline(tolerance, tol)?;
85        if !fitted.met {
86            ogeom_bail!(
87                NotDone,
88                "a surface fitted as a spline stays {} away",
89                fitted.error
90            );
91        }
92        let fitted: SurfaceGeometry = fitted.curve.into();
93        let turned = normals_oppose(s, &fitted, tol)?;
94        Ok(Some((fitted, turned)))
95    };
96    let curve = |c: &Curve, range: (f64, f64)| -> OgeomResult<Option<(Curve, (f64, f64))>> {
97        if let Ok(exact) = c.to_bspline_over(range, tol) {
98            let exact: Curve = exact.into();
99            let domain = exact.domain();
100            return Ok(Some((exact, domain)));
101        }
102        let fitted = c.fitted_bspline_over(range, tolerance, tol)?;
103        if !fitted.met {
104            ogeom_bail!(
105                NotDone,
106                "a curve fitted as a spline stays {} away",
107                fitted.error
108            );
109        }
110        Ok(Some((fitted.curve.into(), range)))
111    };
112    rebuild(model, shape, None, Restate::With(&surface, &curve), tol)
113}
114
115/// Whether `new`'s normal points against `old`'s where they meet, read
116/// off the middle of `old`'s chart: what a restatement reports when it
117/// replaces a surface by one of the same points.
118///
119/// # Errors
120///
121/// As [`project_on_surface`](crate::project_on_surface), and if either
122/// surface has no normal there.
123pub fn normals_oppose(
124    old: &SurfaceGeometry,
125    new: &SurfaceGeometry,
126    tol: Tolerances,
127) -> OgeomResult<bool> {
128    let ((u0, u1), (v0, v1)) = old.domain();
129    // Off the exact middle: a revolution's middle can sit on its axis.
130    let (u, v) = (u0 + (u1 - u0) * 0.43, v0 + (v1 - v0) * 0.57);
131    let p = old.point_at(u, v, tol)?;
132    let n_old = old.normal_at(u, v, tol)?;
133    let at = crate::measure::project_on_surface(new, p, 16, tol)?.parameters;
134    let n_new = new.normal_at(at.0, at.1, tol)?;
135    Ok(n_old.vector().dot(n_new.vector()) < 0.0)
136}
137
138/// Rebuild a solid, a shell, a face, or a compound of them, with its
139/// placements baked into the geometry, keeping every surface and curve in
140/// its own analytic vocabulary.
141///
142/// History records each face, edge occurrence and container modified into
143/// its twin: an edge node placed twice (a prism's two ends) has two twins,
144/// one per placement, each read back through the placed occurrence.
145///
146/// A uniform-scale placement carries a cylinder to a cylinder and a line to
147/// a line (`Transformable` states each exactly), but the stored pcurves
148/// describe the *old* parameterizations. This rebuild restates the geometry
149/// in world space and re-derives every pcurve against it, exact where the
150/// chart alignment allows and projection-fitted with recorded slop where
151/// not, which is what lets a scaled shape enter the boolean's analytic
152/// pipeline instead of refusing.
153///
154/// # Errors
155///
156/// As [`to_nurbs`].
157pub fn baked_shape(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
158    rebuild(model, shape, None, Restate::Keep, tol)
159}
160
161/// The shape with its placements baked in where any is placed, as
162/// [`baked_shape`] rebuilds it; the shape itself, with an empty history,
163/// where no face, surface, edge occurrence or edge curve in it is placed.
164///
165/// What an operation that reads geometry in its own coordinates calls on
166/// its input: a prism's far end edges are its profile's edges under the
167/// prism's translation, and read through this they are edges in place.
168/// [`History::trace`] on the result's history answers where any face or
169/// edge occurrence of the input went.
170///
171/// # Errors
172///
173/// As [`baked_shape`].
174pub fn baked_where_placed(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
175    let mut placed = !shape.location().is_identity();
176    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
177        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
178            ogeom_bail!(Dangling, "face is not in this model");
179        };
180        placed |= !face.location().is_identity() || !data.location.is_identity();
181    }
182    for edge in explore(model, shape, Filter::OfType(ShapeType::Edge))? {
183        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
184            ogeom_bail!(Dangling, "edge is not in this model");
185        };
186        placed |= !edge.location().is_identity()
187            || matches!(data.curve3d(), Some(EdgeRepr::Curve3d { location, .. }) if !location.is_identity());
188    }
189    if placed {
190        baked_shape(model, shape, tol)
191    } else {
192        Ok(Built::new(shape.clone(), History::identity()))
193    }
194}
195
196/// Rebuild a solid, a shell, a face, or a compound of them, under a
197/// general affine transform.
198///
199/// A shear or an uneven scale is not a placement: it carries a circle to an
200/// ellipse and a sphere to something with no analytic name here, so the
201/// shape is converted to its exact B-spline form and the *control points*
202/// are moved, which an affine map does exactly. The pcurves fitted during
203/// conversion survive untouched, because an affine map does not
204/// reparameterize.
205///
206/// A similarity is the other case, and it is a placement. The kernel's own
207/// types already draw that line: [`Transform`](ogeom_math::Transform) is a
208/// similarity by construction and is kept apart from [`GeneralTransform`]
209/// precisely so analytic geometry survives one, so a similarity handed in
210/// here is applied as the placement it is. Converting it would be worse than
211/// wasteful: a restated plane is still that plane but no longer *says* so,
212/// and coincidence downstream is decided on what the geometry says. Two
213/// coplanar faces would stop being coplanar *surfaces*, no closed form would
214/// answer `Same` for them, and the boolean would be handed a coincident pair
215/// with nothing to recognize it by.
216///
217/// The history records the shape and every container, face, wire, edge and
218/// vertex below it as modified into its rebuilt twin, as
219/// [`transformed`](crate::transformed) records each into its moved
220/// occurrence, so `trace` answers for every sub-shape either way.
221///
222/// # Errors
223///
224/// As [`to_nurbs`], or as [`transformed`](crate::transformed) for a similarity.
225pub fn general_transformed_shape(
226    model: &mut Model,
227    shape: &Shape,
228    transform: &GeneralTransform,
229    tol: Tolerances,
230) -> OgeomResult<Built> {
231    if let Some(similarity) = transform.to_similarity(tol.angular()) {
232        return crate::place::transformed(model, shape, similarity);
233    }
234    rebuild(model, shape, Some(transform), Restate::Nurbs, tol)
235}
236
237/// A caller's restatement of a surface: the new surface and whether its
238/// normal points against the old one's, or `None` to keep it.
239pub type SurfaceRestatement<'a> =
240    &'a dyn Fn(&SurfaceGeometry) -> OgeomResult<Option<(SurfaceGeometry, bool)>>;
241
242/// A caller's restatement of a curve over a range: the new curve and the
243/// range on it tracing the same points, or `None` to keep it.
244pub type CurveRestatement<'a> =
245    &'a dyn Fn(&Curve, (f64, f64)) -> OgeomResult<Option<(Curve, (f64, f64))>>;
246
247/// Rebuild a shape with its surfaces and curves restated by the caller,
248/// every placement baked in and every pcurve re-derived against the new
249/// surfaces at the new edges' parameters.
250///
251/// `surface` is handed each face's surface, placed and bounded to the face;
252/// `curve` each edge's curve, placed, with the edge's range on it. Each
253/// answers `None` to keep what it was handed. A restatement must trace the
254/// same points: the rebuild re-derives trims, it does not move boundaries.
255///
256/// # Errors
257///
258/// As [`to_nurbs`], and whatever a restatement returns.
259pub fn restate_geometry(
260    model: &mut Model,
261    shape: &Shape,
262    surface: SurfaceRestatement<'_>,
263    curve: CurveRestatement<'_>,
264    tol: Tolerances,
265) -> OgeomResult<Built> {
266    rebuild(model, shape, None, Restate::With(surface, curve), tol)
267}
268
269/// How a rebuild restates the geometry it carries.
270#[derive(Clone, Copy)]
271enum Restate<'a> {
272    /// As it is, placed.
273    Keep,
274    /// As B-splines, exactly.
275    Nurbs,
276    /// As the caller says.
277    With(SurfaceRestatement<'a>, CurveRestatement<'a>),
278}
279
280/// The shared engine: convert, refit, optionally move control points.
281fn rebuild(
282    model: &mut Model,
283    shape: &Shape,
284    affine: Option<&GeneralTransform>,
285    restate: Restate<'_>,
286    tol: Tolerances,
287) -> OgeomResult<Built> {
288    let mut state = Rebuild {
289        affine,
290        restate,
291        tol,
292        history: History::new(),
293        new_vertices: NewVertices::default(),
294        new_edges: FastMap::default(),
295        rederived: Vec::new(),
296    };
297    let rebuilt = state.shape(model, shape)?;
298    // A re-derived pcurve is held to its target where it was built, at
299    // its samples; each such edge states how far its pcurves stand off
300    // its curve anywhere along it.
301    crate::pcurve_gap::state_pcurve_gaps_of(model, &state.rederived, tol)?;
302    Ok(Built::new(rebuilt, state.history))
303}
304
305/// A rebuild under way: how it restates, and what it has made so far.
306struct Rebuild<'a> {
307    affine: Option<&'a GeneralTransform>,
308    restate: Restate<'a>,
309    tol: Tolerances,
310    history: History,
311    /// Each vertex occurrence's twin, by node and position.
312    new_vertices: NewVertices,
313    /// Each edge occurrence's twin, by node and placement.
314    new_edges: FastMap<(TShapeId, [u64; 3]), ConvertedEdge>,
315    /// The edges given a pcurve that is not a closed form of their curve
316    /// on the new surface: an iso line or a fit.
317    rederived: Vec<Shape>,
318}
319
320impl Rebuild<'_> {
321    /// A solid, a shell, a face, or a compound of them, rebuilt: each
322    /// container anew over its rebuilt children, which carry the
323    /// container's orientation and placement down.
324    fn shape(&mut self, model: &mut Model, shape: &Shape) -> OgeomResult<Shape> {
325        let kind = model.kind_of(shape)?;
326        let rebuilt = match kind {
327            ShapeType::Face => return self.face(model, shape),
328            ShapeType::Shell | ShapeType::Solid | ShapeType::Compound => {
329                let mut children = Vec::new();
330                for child in model.ordered_children_of(shape)? {
331                    children.push(self.shape(model, &child)?);
332                }
333                match kind {
334                    ShapeType::Shell => make_shell(model, &children)?.shape,
335                    ShapeType::Solid => make_solid(model, &children)?.shape,
336                    _ => make_compound(model, &children)?.shape,
337                }
338            }
339            other => ogeom_bail!(
340                Construction,
341                "whole-shape conversion rebuilds solids, shells, faces and \
342                 compounds of them; this is a {other:?}"
343            ),
344        };
345        self.history.modify(shape, rebuilt.clone());
346        Ok(rebuilt)
347    }
348
349    /// One face rebuilt on its restated surface, bounded by its restated
350    /// edges, each with a pcurve re-derived on the new surface.
351    #[expect(
352        clippy::too_many_lines,
353        reason = "one walk over a face's wires, restating each edge in place"
354    )]
355    fn face(&mut self, model: &mut Model, face: &Shape) -> OgeomResult<Shape> {
356        let (affine, restate, tol) = (self.affine, self.restate, self.tol);
357        let map = |p: Point| affine.map_or(p, |t| t.apply(p));
358        // The fit target for re-derived pcurves: comfortably inside the
359        // model's own working band.
360        let target = tol.confusion() * 1e2;
361        let face = face.clone();
362        let history = &mut self.history;
363        let new_vertices = &mut self.new_vertices;
364        let new_edges = &mut self.new_edges;
365        let rederived = &mut self.rederived;
366        let placement = face.transform(model.datums())?;
367        let (surface_id_old, old_surface) = {
368            let Some(node) = model.node(&face) else {
369                ogeom_bail!(Dangling, "face is not in this model");
370            };
371            let NodeData::Face(data) = node.data() else {
372                ogeom_bail!(Construction, "face node holds no face data");
373            };
374            let Some(surface) = model.geometry().surface(data.surface) else {
375                ogeom_bail!(Dangling, "face refers to a surface not in this model");
376            };
377            (data.surface, surface.clone())
378        };
379        use ogeom_geom::Transformable as _;
380        let placed = old_surface.transformed(&placement, tol)?;
381        // Bounded to the face's own chart region first: a plane declares
382        // an enormous domain, and a patch over all of it would map the
383        // face to a dot in its chart.
384        let (placed, old_window) = bounded_to_face(
385            model,
386            &face,
387            surface_id_old,
388            &placed,
389            placement.scale_factor().abs(),
390            tol,
391        )?;
392        let mut flipped = false;
393        // Under an affine map the turn is measured on the spline before
394        // the map moves it, where the old surface's normal still means
395        // something, and the map's own reflection goes on top.
396        let mut turned_before_affine = None;
397        let patch_surface: SurfaceGeometry = match restate {
398            Restate::Nurbs => {
399                let mut patch = profile_held(&placed, old_window, tol).to_bspline(tol)?;
400                if let Some(t) = affine {
401                    let unmapped: SurfaceGeometry = patch.clone().into();
402                    turned_before_affine =
403                        turned_by_rebuild(&old_surface, &placement, old_window, &unmapped, tol)
404                            .map(|turned| turned != (t.linear.determinant() < 0.0));
405                    patch = transformed_patch(&patch, t)?;
406                }
407                patch.into()
408            }
409            Restate::Keep => on_right_handed_frame(&placed, tol)?.unwrap_or_else(|| placed.clone()),
410            Restate::With(surface, _) => match surface(&placed)? {
411                Some((restated, flip)) => {
412                    flipped = flip;
413                    restated
414                }
415                None => placed.clone(),
416            },
417        };
418        let surface_id = model.geometry_mut().add_surface(patch_surface.clone());
419
420        // The rebuilt chart is right-handed wherever the old one stood;
421        // a reflecting placement flipped the old chart's natural normal,
422        // and the flag must carry that flip or the baked solid comes
423        // out inside-out.
424        // A restated surface whose normal turned is the same flip.
425        //
426        // Whether a reflection turns a surface's natural normal depends
427        // on the surface: a mirrored cylinder is stored about a
428        // right-handed frame again and keeps its normal away from the
429        // axis. So where it can be, the turn is measured: the old
430        // normal carried through the placement against the new one at
431        // the same point. Handedness is the fallback where the
432        // measurement cannot be taken, and under an affine map, which
433        // does not carry normals as vectors.
434        let measured = if affine.is_none() {
435            turned_by_rebuild(&old_surface, &placement, old_window, &patch_surface, tol)
436        } else {
437            turned_before_affine
438        };
439        let reflecting = !face.location().preserves_handedness(model.datums())?;
440        let reflected = measured.unwrap_or(reflecting != flipped);
441        // The rings keep the face on their left in the old chart. A spline
442        // whose control points the affine map moved keeps that chart; any
443        // other new chart runs the other way round where the placement
444        // reflects and the normal did not turn with it, or the normal
445        // turned under a placement that does not reflect, and the rings
446        // are walked back to keep the face on their left in it.
447        let walked_back = affine.is_none() && reflecting != reflected;
448
449        let mut rings = Vec::new();
450        let mut old_wires = Vec::new();
451        let mut corner_uv: FastMap<TShapeId, Point2> = FastMap::default();
452        // The wires are read as the face stores them, its own sense left
453        // out: the rebuilt face takes that sense back below, and reading
454        // them under it as well would turn every ring of a reversed face
455        // around.
456        for wire in model.ordered_children_of(&face.oriented(Orientation::Forward))? {
457            let mut ring = Vec::new();
458            let mut seams_done: Vec<TShapeId> = Vec::new();
459            for edge in model.ordered_children_of(&wire)? {
460                let edge_placement = edge.transform(model.datums())?;
461                let key = (edge.node(), placement_bits(&edge_placement));
462                let data = {
463                    let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
464                        ogeom_bail!(Construction, "edge node holds no edge data");
465                    };
466                    data.clone()
467                };
468                if data.degenerate {
469                    // A pole or an apex: still an edge in parameter
470                    // space; its pcurve is rebuilt below from the old
471                    // chart row's place in the new chart.
472                    let Some(vertex) = model.children_of(&edge)?.first().cloned() else {
473                        ogeom_bail!(Construction, "a degenerate edge has no vertex");
474                    };
475                    let at = {
476                        let Some(v) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
477                            ogeom_bail!(Construction, "vertex node holds no vertex data");
478                        };
479                        map(vertex.transform(model.datums())?.apply(v.point))
480                    };
481                    let reach = model
482                        .node(&vertex)
483                        .and_then(|n| n.data().as_vertex())
484                        .map_or(0.0, |v| v.tolerance.get());
485                    let new_vertex = new_vertices.at(model, vertex.node(), at, reach, tol);
486                    let mut degenerate = ogeom_topo::EdgeData::new();
487                    degenerate.degenerate = true;
488                    let new_edge = model.add_edge(degenerate, &[new_vertex.clone(), new_vertex])?;
489                    record(history, &edge, &new_edge);
490                    record_vertices(model, &edge, &map, new_vertices, history, tol)?;
491                    let row = degenerate_row(
492                        model,
493                        &data,
494                        surface_id_old,
495                        &edge,
496                        &placed,
497                        &patch_surface,
498                        at,
499                        walked_back,
500                        tol,
501                    )?;
502                    attach_pcurve(
503                        model,
504                        &new_edge,
505                        row.into(),
506                        surface_id,
507                        Location::identity(),
508                        row.domain(),
509                    )?;
510                    ring.push(if edge.orientation() == Orientation::Reversed {
511                        new_edge.reversed()
512                    } else {
513                        new_edge
514                    });
515                    continue;
516                }
517
518                // An occurrence placed differently may still lie where
519                // one already converted lies (a rotated copy of a profile
520                // shares its edge on the axis of the turn), and is then
521                // that edge.
522                let found = match new_edges.get(&key) {
523                    Some(found) => Some(found.clone()),
524                    None => {
525                        let at = occurrence_points(model, &edge, &data, &map, tol)?;
526                        let same = new_edges
527                            .iter()
528                            .filter(|((node, _), _)| *node == edge.node())
529                            .find(|(_, (_, curve, range))| lies_at(curve, *range, at, tol))
530                            .map(|(_, found)| found.clone());
531                        if let Some(same) = &same {
532                            new_edges.insert(key, same.clone());
533                        }
534                        same
535                    }
536                };
537                let (new_edge, new_curve, new_range) = match found {
538                    Some(found) => found,
539                    None => {
540                        let built =
541                            convert_edge(model, &edge, &data, &map, restate, new_vertices, tol)?;
542                        new_edges.insert(key, built.clone());
543                        built
544                    }
545                };
546                record(history, &edge, &new_edge);
547                record_vertices(model, &edge, &map, new_vertices, history, tol)?;
548
549                // The pcurve on this face: fitted at the new edge's own
550                // parameters, seam sides each fitted against their own
551                // half of the chart.
552                let old_repr = data.pcurve_for(surface_id_old, edge.location()).cloned();
553                let was_seam = matches!(old_repr, Some(EdgeRepr::Seam { .. }));
554                if was_seam {
555                    if !seams_done.contains(&new_edge.node()) {
556                        seams_done.push(new_edge.node());
557                        // Whether the old seam ran along a row or a column.
558                        let old_iso = match &old_repr {
559                            Some(EdgeRepr::Seam { forward, range, .. }) => {
560                                match model.geometry().pcurve(*forward) {
561                                    Some(side) => iso_direction(side, *range, tol)?,
562                                    None => None,
563                                }
564                            }
565                            _ => None,
566                        };
567                        let exact =
568                            match exact_seam_columns(&new_curve, new_range, &patch_surface, tol) {
569                                Some(sides) => Some(sides),
570                                None => match old_iso {
571                                    Some(row) => {
572                                        seam_lines(&new_curve, new_range, &patch_surface, row, tol)?
573                                    }
574                                    None => None,
575                                },
576                            };
577                        let (forward, reversed) = match exact {
578                            Some(sides) => sides,
579                            None => {
580                                seam_pcurves(&new_curve, new_range, &patch_surface, target, tol)?
581                            }
582                        };
583                        let seam_range = forward.domain();
584                        rederived.push(new_edge.clone());
585                        attach_seam(
586                            model,
587                            &new_edge,
588                            forward,
589                            reversed,
590                            surface_id,
591                            Location::identity(),
592                            seam_range,
593                        )?;
594                    }
595                } else {
596                    // The iso path: an edge that ran along the old chart's
597                    // iso direction runs along the new chart's. An exact
598                    // conversion shares the boundary's parameterization by
599                    // construction; a fitted surface has its own, and its
600                    // iso line may stray from the edge, so the line is kept
601                    // only where it follows the edge within the fit target.
602                    let stated = model
603                        .node(&new_edge)
604                        .and_then(|n| n.data().as_edge())
605                        .map_or(0.0, |d| d.tolerance.get());
606                    let iso = exact_iso_pcurve(
607                        model,
608                        old_repr.as_ref(),
609                        old_window,
610                        &new_curve,
611                        new_range,
612                        &patch_surface,
613                        (edge.orientation() == Orientation::Reversed) == walked_back,
614                        tol,
615                    )?;
616                    let iso = match iso {
617                        Some(iso)
618                            if crate::pcurve_gap::pcurve_gap(
619                                (&new_curve, new_range),
620                                (&iso, iso.domain()),
621                                &patch_surface,
622                                stated,
623                                tol,
624                            )? <= target.max(stated) =>
625                        {
626                            Some(iso)
627                        }
628                        _ => None,
629                    };
630                    if let Some(iso) = iso {
631                        let iso_range = iso.domain();
632                        rederived.push(new_edge.clone());
633                        attach_pcurve(
634                            model,
635                            &new_edge,
636                            iso,
637                            surface_id,
638                            Location::identity(),
639                            iso_range,
640                        )?;
641                        ring.push(if edge.orientation() == Orientation::Reversed {
642                            new_edge.reversed()
643                        } else {
644                            new_edge
645                        });
646                        continue;
647                    }
648                    let uv_of = |model: &Model,
649                                 cache: &mut FastMap<TShapeId, Point2>,
650                                 vertex: &Shape|
651                     -> OgeomResult<Point2> {
652                        if let Some(&uv) = cache.get(&vertex.node()) {
653                            return Ok(uv);
654                        }
655                        let Some(v) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
656                            ogeom_bail!(Construction, "vertex node holds no vertex data");
657                        };
658                        let projection =
659                            crate::measure::project_on_surface(&patch_surface, v.point, 24, tol)?;
660                        let uv = Point2::new(projection.parameters.0, projection.parameters.1);
661                        cache.insert(vertex.node(), uv);
662                        Ok(uv)
663                    };
664                    let bounds = model.children_of(&new_edge)?;
665                    // A closed edge (a rim) has one vertex at both
666                    // ends, and near a seam its single chart image is
667                    // one side's; pinning both ends there would fold the
668                    // ring. Its trace closes on its own.
669                    let closed_edge =
670                        bounds.len() < 2 || bounds[0].node() == bounds[bounds.len() - 1].node();
671                    let ends = if closed_edge {
672                        (None, None)
673                    } else {
674                        (
675                            Some(uv_of(model, &mut corner_uv, &bounds[0])?),
676                            Some(uv_of(model, &mut corner_uv, &bounds[bounds.len() - 1])?),
677                        )
678                    };
679                    // Exact wherever the chart has a closed form: the
680                    // melt downstream compares these images against
681                    // exact geometry, and a fitted stand-in for a
682                    // closed-form projection carries slop for nothing.
683                    let derived = match ogeom_intersect::exact_pcurve_over(
684                        &new_curve,
685                        new_range,
686                        &patch_surface,
687                        tol,
688                    ) {
689                        Some(exact) => exact,
690                        None => {
691                            let fitted = fit_pcurve(
692                                &new_curve,
693                                new_range,
694                                &patch_surface,
695                                None,
696                                ends,
697                                target,
698                                tol,
699                            )?;
700                            // The fit's honest slop rides the edge, so
701                            // every downstream filter widens by it,
702                            // and rides its vertices, which bound the
703                            // edge and cannot be held tighter than it.
704                            let widened = if let Some(node) = model.node_mut(&new_edge)
705                                && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
706                            {
707                                data.tolerance = data.tolerance.widen_to(target);
708                                Some(data.tolerance)
709                            } else {
710                                None
711                            };
712                            if let Some(widened) = widened {
713                                for vertex in model.ordered_children_of(&new_edge)? {
714                                    model.widen(&vertex, widened)?;
715                                }
716                            }
717                            rederived.push(new_edge.clone());
718                            fitted
719                        }
720                    };
721                    attach_pcurve(
722                        model,
723                        &new_edge,
724                        derived,
725                        surface_id,
726                        Location::identity(),
727                        new_range,
728                    )?;
729                }
730                ring.push(if edge.orientation() == Orientation::Reversed {
731                    new_edge.reversed()
732                } else {
733                    new_edge
734                });
735            }
736            rings.push(ring);
737            old_wires.push(wire);
738        }
739        let mut wires = Vec::with_capacity(rings.len());
740        for (ring, old) in rings.into_iter().zip(&old_wires) {
741            let ring: Vec<Shape> = if walked_back {
742                ring.iter().rev().map(Shape::reversed).collect()
743            } else {
744                ring
745            };
746            let wire = make_wire(model, &ring, tol)?.shape;
747            record(history, old, &wire);
748            wires.push(wire);
749        }
750        let built = make_face_on(model, surface_id, &wires, tol)?.shape;
751        let built = if (face.orientation() == Orientation::Reversed) != reflected {
752            built.reversed()
753        } else {
754            built
755        };
756        history.modify(&face, built.clone());
757        Ok(built)
758    }
759}
760
761/// An analytic surface placed by a reflection, restated on its frame's
762/// right-handed twin (the same origin and axis, `y` reversed): the same
763/// points, turning the other way. Downstream code reads a surface's frame
764/// as right-handed, as every surface built from scratch has it; the face's
765/// flag takes the turn, which the rebuild measures. A plane's `v` runs the
766/// other way on the twin, and its window with it. `None` for a surface on
767/// a right-handed frame, or of another kind.
768fn on_right_handed_frame(
769    surface: &SurfaceGeometry,
770    tol: Tolerances,
771) -> OgeomResult<Option<SurfaceGeometry>> {
772    use ogeom_geom::{ConeSurface, CylinderSurface, PlaneSurface, SphereSurface, TorusSurface};
773    use ogeom_math::{Cone, Cylinder, Frame, Handedness, Plane, Sphere, Torus};
774    let twin = |frame: Frame| -> OgeomResult<Option<Frame>> {
775        if frame.handedness() == Handedness::Right {
776            return Ok(None);
777        }
778        Ok(Some(Frame::new(frame.origin(), frame.z(), frame.x(), tol)?))
779    };
780    let ((u0, u1), (v0, v1)) = surface.domain();
781    Ok(match surface {
782        SurfaceGeometry::Plane(p) => match twin(p.plane().frame())? {
783            Some(f) => Some(PlaneSurface::over(Plane::new(f), (u0, u1), (-v1, -v0))?.into()),
784            None => None,
785        },
786        SurfaceGeometry::Cylinder(c) => {
787            let cylinder = c.cylinder();
788            match twin(cylinder.frame())? {
789                Some(f) => Some(
790                    CylinderSurface::new(Cylinder::new(f, cylinder.radius(), tol)?, (v0, v1))?
791                        .into(),
792                ),
793                None => None,
794            }
795        }
796        SurfaceGeometry::Cone(c) => {
797            let cone = c.cone();
798            match twin(cone.frame())? {
799                Some(f) => Some(
800                    ConeSurface::new(
801                        Cone::new(f, cone.reference_radius(), cone.half_angle(), tol)?,
802                        (v0, v1),
803                    )?
804                    .into(),
805                ),
806                None => None,
807            }
808        }
809        SurfaceGeometry::Sphere(s) => {
810            let sphere = s.sphere();
811            match twin(sphere.frame())? {
812                Some(f) => Some(SphereSurface::new(Sphere::new(f, sphere.radius(), tol)?).into()),
813                None => None,
814            }
815        }
816        SurfaceGeometry::Torus(t) => {
817            let torus = t.torus();
818            match twin(torus.frame())? {
819                Some(f) => Some(
820                    TorusSurface::new(Torus::new(
821                        f,
822                        torus.major_radius(),
823                        torus.minor_radius(),
824                        tol,
825                    )?)
826                    .into(),
827                ),
828                None => None,
829            }
830        }
831        _ => None,
832    })
833}
834
835/// An edge occurrence's start, middle and end, placed and mapped.
836fn occurrence_points(
837    model: &Model,
838    edge: &Shape,
839    data: &ogeom_topo::EdgeData,
840    map: &dyn Fn(Point) -> Point,
841    tol: Tolerances,
842) -> OgeomResult<Option<[Point; 3]>> {
843    let Some(EdgeRepr::Curve3d {
844        curve,
845        range,
846        location,
847    }) = data.curve3d()
848    else {
849        return Ok(None);
850    };
851    let Some(geometry) = model.geometry().curve(*curve) else {
852        return Ok(None);
853    };
854    let placement = edge.transform(model.datums())? * location.composed(model.datums())?;
855    let mut out = [Point::ORIGIN; 3];
856    for (slot, t) in out
857        .iter_mut()
858        .zip([range.0, f64::midpoint(range.0, range.1), range.1])
859    {
860        *slot = map(placement.apply(geometry.point_at(t, tol)?));
861    }
862    Ok(Some(out))
863}
864
865/// Whether a converted curve over `range` runs through `at`'s start,
866/// middle and end.
867fn lies_at(curve: &Curve, range: (f64, f64), at: Option<[Point; 3]>, tol: Tolerances) -> bool {
868    let Some([a, m, b]) = at else {
869        return false;
870    };
871    let near = |t: f64, p: Point| {
872        curve
873            .point_at(t, tol)
874            .is_ok_and(|q| q.distance(p) <= tol.confusion() * 1e2)
875    };
876    near(range.0, a) && near(f64::midpoint(range.0, range.1), m) && near(range.1, b)
877}
878
879/// One edge converted: exact B-spline over its range, vertices carried.
880fn convert_edge(
881    model: &mut Model,
882    edge: &Shape,
883    data: &ogeom_topo::EdgeData,
884    map: &dyn Fn(Point) -> Point,
885    restate: Restate<'_>,
886    vertices: &mut NewVertices,
887    tol: Tolerances,
888) -> OgeomResult<(Shape, Curve, (f64, f64))> {
889    let Some(EdgeRepr::Curve3d {
890        curve,
891        range,
892        location,
893    }) = data.curve3d()
894    else {
895        ogeom_bail!(Construction, "an edge with no curve cannot be converted");
896    };
897    let Some(geometry) = model.geometry().curve(*curve).cloned() else {
898        ogeom_bail!(Dangling, "curve is not in this model");
899    };
900    let placement = edge.transform(model.datums())? * location.composed(model.datums())?;
901    use ogeom_geom::Transformable as _;
902    let placed = geometry.transformed(&placement, tol)?;
903    // A placement with scale rescales a length-parameterized curve's domain
904    // (a line's, a parabola's, anything built on one), so the edge's range
905    // moves with the domain or the conversion covers only part of the edge.
906    let (from, to) = (geometry.domain(), placed.domain());
907    let range_on_placed = (
908        ogeom_geom::carried(range.0, from, to),
909        ogeom_geom::carried(range.1, from, to),
910    );
911    let (curve, new_range): (Curve, (f64, f64)) = if matches!(restate, Restate::Nurbs) {
912        let spline = placed.to_bspline_over(range_on_placed, tol)?;
913        // The affine map moves control points; the parameterization and the
914        // weights stay, which is the whole point of converting first.
915        let weighted: Vec<Weighted<Point>> = spline
916            .control_points()
917            .iter()
918            .map(|c| {
919                let p = Point::from_vector(c.scaled.to_vector() / c.weight);
920                Weighted::new(map(p), c.weight, tol)
921            })
922            .collect::<OgeomResult<_>>()?;
923        let spline = ogeom_geom::BSplineCurve::rational(spline.knots().clone(), weighted)?;
924        let curve: Curve = spline.into();
925        let range = curve.domain();
926        (curve, range)
927    } else if let Restate::With(_, restated) = restate {
928        restated(&placed, range_on_placed)?.unwrap_or((placed, range_on_placed))
929    } else {
930        (placed, range_on_placed)
931    };
932    // Vertices are shared across every edge that meets them: cached by the
933    // old node and the old vertex's own mapped point: the point every
934    // neighbouring edge computes, unlike each spline's evaluated end.
935    let old = model.children_of(edge)?;
936    if old.is_empty() {
937        ogeom_bail!(Construction, "an edge with no vertices cannot be converted");
938    }
939    let mut resolve = |model: &mut Model, occurrence: &Shape| -> OgeomResult<Shape> {
940        let Some(data) = model
941            .node(occurrence)
942            .and_then(|n| n.data().as_vertex().cloned())
943        else {
944            ogeom_bail!(Construction, "vertex node holds no vertex data");
945        };
946        // The vertex's *own* composed placement, not the edge's: a prism's
947        // far cap references the profile's vertex nodes under the travel,
948        // and an edge placed identically still ends on a moved vertex.
949        let at = map(occurrence.transform(model.datums())?.apply(data.point));
950        let fresh = vertices.at(model, occurrence.node(), at, data.tolerance.get(), tol);
951        // The old vertex's recorded slop (a file's, or a fit's) is the
952        // new one's too: the curves it meets are the same curves, moved
953        // exactly, and a vertex born at the default tolerance would refuse
954        // the edge its twin accepted.
955        if data.tolerance.get() > tol.confusion() {
956            model.widen(&fresh, data.tolerance)?;
957        }
958        Ok(fresh)
959    };
960    let va = resolve(model, &old[0])?;
961    let vb = if old.len() == 1 {
962        va.clone()
963    } else {
964        resolve(model, &old[old.len() - 1])?
965    };
966    let built = make_edge_between(model, curve.clone(), new_range, &va, &vb, tol)?.shape;
967    if data.tolerance.get() > tol.confusion() {
968        model.widen(&built, data.tolerance)?;
969    }
970    Ok((built, curve, new_range))
971}
972
973/// A pcurve refit at the edge's own parameters, optionally biased toward one
974/// side of the chart's seam (`side`: whether the seam is a `u` column, and
975/// the column or row): sampled more densely until its image lies
976/// within `target` of the edge halfway between the samples too, which a fit
977/// held only at them need not.
978#[allow(clippy::too_many_arguments)]
979fn fit_pcurve(
980    curve: &Curve,
981    range: (f64, f64),
982    surface: &SurfaceGeometry,
983    side: Option<(bool, f64)>,
984    ends: (Option<Point2>, Option<Point2>),
985    target: f64,
986    tol: Tolerances,
987) -> OgeomResult<ogeom_geom::PlanarCurve> {
988    use ogeom_geom::Curve2d as _;
989    let mut samples = 48;
990    loop {
991        let fitted = fit_pcurve_at(curve, range, surface, side, ends, target, samples, tol)?;
992        if samples >= 384 {
993            return Ok(fitted);
994        }
995        let mut held = true;
996        for i in 0..samples {
997            #[allow(clippy::cast_precision_loss)]
998            let t = range.0 + (range.1 - range.0) * (i as f64 + 0.5) / samples as f64;
999            let at = fitted.point_at(t, tol)?;
1000            // Where the fit overhangs the chart at a closure there is no
1001            // surface to lift it to; its samples there hold it.
1002            let Ok(lifted) = surface.point_at(at.x, at.y, tol) else {
1003                continue;
1004            };
1005            if lifted.distance(curve.point_at(t, tol)?) > target {
1006                held = false;
1007                break;
1008            }
1009        }
1010        if held {
1011            return Ok(fitted);
1012        }
1013        samples *= 2;
1014    }
1015}
1016
1017/// [`fit_pcurve`] through `samples` spans of the edge.
1018#[allow(clippy::too_many_arguments)]
1019fn fit_pcurve_at(
1020    curve: &Curve,
1021    range: (f64, f64),
1022    surface: &SurfaceGeometry,
1023    side: Option<(bool, f64)>,
1024    ends: (Option<Point2>, Option<Point2>),
1025    target: f64,
1026    samples: usize,
1027    tol: Tolerances,
1028) -> OgeomResult<ogeom_geom::PlanarCurve> {
1029    let ((u0, u1), (v0, v1)) = surface.domain();
1030    let mut parameters = Vec::with_capacity(samples + 1);
1031    let mut trace = Vec::with_capacity(samples + 1);
1032    for i in 0..=samples {
1033        #[allow(clippy::cast_precision_loss)]
1034        let t = range.0 + (range.1 - range.0) * i as f64 / samples as f64;
1035        let p = curve.point_at(t, tol)?;
1036        let projection = crate::measure::project_on_surface(surface, p, 24, tol)?;
1037        let (mut u, mut v) = projection.parameters;
1038        // A point exactly on the seam projects to either side; the bias
1039        // names which column, or row, this pcurve is.
1040        match side {
1041            Some((true, bias))
1042                if (u - u0).abs() < (u1 - u0) * 0.25 || (u - u1).abs() < (u1 - u0) * 0.25 =>
1043            {
1044                u = bias;
1045            }
1046            Some((false, bias))
1047                if (v - v0).abs() < (v1 - v0) * 0.25 || (v - v1).abs() < (v1 - v0) * 0.25 =>
1048            {
1049                v = bias;
1050            }
1051            _ => {}
1052        }
1053        parameters.push(t);
1054        trace.push(Point2::new(u, v));
1055    }
1056    // A sample on a pole images to every column there, and the projection
1057    // answers with any of them; the trace reaches the pole along the column
1058    // of the sample beside it, which is where it stands in the chart. A
1059    // pole is where the chart's whole width of columns spans no more than
1060    // the confusion distance: the rate across columns times the width.
1061    let on_pole: Vec<bool> = trace
1062        .iter()
1063        .map(|p| {
1064            surface
1065                .d1_at(p.x, p.y, tol)
1066                .is_ok_and(|(du, _)| du.magnitude() * (u1 - u0).abs() <= tol.confusion())
1067        })
1068        .collect();
1069    if on_pole.iter().any(|&p| p) && !on_pole.iter().all(|&p| p) {
1070        for i in 0..trace.len() {
1071            if !on_pole[i] {
1072                continue;
1073            }
1074            let beside = (1..trace.len())
1075                .flat_map(|k| [i.checked_sub(k), (i + k < trace.len()).then_some(i + k)])
1076                .flatten()
1077                .find(|&j| !on_pole[j]);
1078            if let Some(j) = beside {
1079                trace[i].x = trace[j].x;
1080            }
1081        }
1082    }
1083    // A closed patch is clamped, not periodic, but its geometry still closes:
1084    // a trace crossing the closure jumps by the whole u span. Unwrapped, the
1085    // fit sees the continuous curve the edge is; the pcurve may leave [u0,u1]
1086    // by a little at the join, which evaluation tolerates.
1087    let span = u1 - u0;
1088    for i in 1..trace.len() {
1089        let mut du = trace[i].x - trace[i - 1].x;
1090        while du > span * 0.5 {
1091            trace[i].x -= span;
1092            du = trace[i].x - trace[i - 1].x;
1093        }
1094        while du < -span * 0.5 {
1095            trace[i].x += span;
1096            du = trace[i].x - trace[i - 1].x;
1097        }
1098    }
1099    // Ring corners are shared property: every edge meeting a vertex uses the
1100    // vertex's own chart image, projected once per face, so the ring closes
1101    // exactly instead of to each fit's own rounding. A vertex on the
1102    // closure has an image on either side of it, and the one projected for
1103    // the ring need not be the side this edge arrives at: it is taken the
1104    // whole periods round that put it where the unwrapped trace stands.
1105    let beside = |corner: Point2, at: Point2| -> Point2 {
1106        if !span.is_finite() || span <= 0.0 {
1107            return corner;
1108        }
1109        let turns = ((at.x - corner.x) / span).round();
1110        Point2::new(turns.mul_add(span, corner.x), corner.y)
1111    };
1112    // A corner on a pole names its row only: the column stays the one the
1113    // trace arrives along.
1114    let at_pole = |k: usize| on_pole.get(k).copied().unwrap_or(false);
1115    let last_index = trace.len().saturating_sub(1);
1116    if let Some(start) = ends.0
1117        && let Some(first) = trace.first_mut()
1118    {
1119        *first = if at_pole(0) {
1120            Point2::new(first.x, start.y)
1121        } else {
1122            beside(start, *first)
1123        };
1124    }
1125    if let Some(end) = ends.1
1126        && let Some(last) = trace.last_mut()
1127    {
1128        *last = if at_pole(last_index) {
1129            Point2::new(last.x, end.y)
1130        } else {
1131            beside(end, *last)
1132        };
1133    }
1134    // The target holds in space, where the edge is compared with its
1135    // pcurve's image; the chart stretches a step in `u` or `v` by up to its
1136    // largest partial along the trace, so the fit answers to that in the
1137    // chart, with half again for the stretch between samples.
1138    let mut stretch: f64 = 1.0;
1139    for p in &trace {
1140        let (du, dv) = surface.d1_at(p.x, p.y, tol)?;
1141        stretch = stretch.max(du.magnitude()).max(dv.magnitude());
1142    }
1143    let fitted =
1144        ogeom_geom::fit::fit_points_2d_at(&parameters, &trace, 3, target / (2.0 * stretch), tol)?;
1145    Ok(fitted.curve.into())
1146}
1147
1148/// An exact iso pcurve in the new chart, when the old one was an iso line.
1149///
1150/// Both endpoints project onto the patch; the pcurve is the straight chart
1151/// segment between them, exact because the boundary conversion and the patch
1152/// direction share one parameterization. `None` for anything that was not an
1153/// axis-aligned chart line, which takes the fitted path. `along` says
1154/// whether the face's ring, which keeps the face on its left in the new
1155/// chart, runs the edge the way its curve does.
1156#[allow(clippy::too_many_arguments)]
1157fn exact_iso_pcurve(
1158    model: &Model,
1159    old_repr: Option<&EdgeRepr>,
1160    _old_window: ChartWindow,
1161    curve: &Curve,
1162    range: (f64, f64),
1163    surface: &SurfaceGeometry,
1164    along: bool,
1165    tol: Tolerances,
1166) -> OgeomResult<Option<ogeom_geom::PlanarCurve>> {
1167    let Some(EdgeRepr::PCurve {
1168        curve: old_pc,
1169        range: old_range,
1170        ..
1171    }) = old_repr
1172    else {
1173        return Ok(None);
1174    };
1175    let Some(old_pcurve) = model.geometry().pcurve(*old_pc) else {
1176        return Ok(None);
1177    };
1178    // An iso line however it is stated: a line, or a spline (a divided
1179    // face's cut is a linear one), whose points all share one coordinate.
1180    let Some(row) = iso_direction(old_pcurve, *old_range, tol)? else {
1181        return Ok(None);
1182    };
1183    let ((nu0, nu1), (nv0, nv1)) = surface.domain();
1184    let start = curve.point_at(range.0, tol)?;
1185    let end = curve.point_at(range.1, tol)?;
1186    let mut a = foot(surface, start, tol)?;
1187    let mut b = foot(surface, end, tol)?;
1188    let closed_u = surface.is_periodic_u() || surface.is_closed_u(tol);
1189    let closed_v = surface.is_periodic_v() || surface.is_closed_v(tol);
1190    // The image of the edge's point at `t`.
1191    let inside = |t: f64| -> OgeomResult<Point2> { foot(surface, curve.point_at(t, tol)?, tol) };
1192    if row {
1193        // A row's v is one value; the endpoints agree up to projection noise.
1194        let mut v = f64::midpoint(a.y, b.y);
1195        (a.x, b.x) = run_ends(
1196            (a.x, b.x),
1197            (nu0, nu1),
1198            closed_u,
1199            range,
1200            |t| Ok(inside(t)?.x),
1201        )?;
1202        // A row the ring runs along the chart has the face above it in `v`.
1203        if let Some(side) = beside_face(&[a.y, b.y], (nv0, nv1), closed_v, (b.x > a.x) == along) {
1204            v = side;
1205        }
1206        a.y = v;
1207        b.y = v;
1208    } else {
1209        // A column's end on a pole images to every u there, and the
1210        // projection answers with any of them; the edge's middle images
1211        // to its own column. Of the three, the median is the column
1212        // whichever end, if either, stands on a pole.
1213        let m = inside(f64::midpoint(range.0, range.1))?.x;
1214        let mut u = a.x.max(b.x).min(m).max(a.x.min(b.x));
1215        (a.y, b.y) = run_ends(
1216            (a.y, b.y),
1217            (nv0, nv1),
1218            closed_v,
1219            range,
1220            |t| Ok(inside(t)?.y),
1221        )?;
1222        // A column the ring runs up the chart has the face below it in `u`.
1223        if let Some(side) = beside_face(&[u], (nu0, nu1), closed_u, (b.y > a.y) != along) {
1224            u = side;
1225        }
1226        a.x = u;
1227        b.x = u;
1228    }
1229    Ok(Some(ogeom_geom::Line2d::segment(a, b, tol)?.into()))
1230}
1231
1232/// Where `p` stands in `surface`'s chart: its projection, polished by
1233/// Newton steps on the foot conditions until a step moves the parameters
1234/// less than the parametric tolerance. The projection stops once the gap
1235/// along the surface is under the confusion distance, which on a surface
1236/// curving tightly (a thin tube) is a sizeable part of the chart; an iso
1237/// line placed there stands that far off the line it images.
1238fn foot(surface: &SurfaceGeometry, p: Point, tol: Tolerances) -> OgeomResult<Point2> {
1239    use ogeom_geom::Surface as _;
1240    let (mut u, mut v) = crate::measure::project_on_surface(surface, p, 24, tol)?.parameters;
1241    for _ in 0..8 {
1242        let Ok(jet) = surface.jet_at(u, v, tol) else {
1243            break;
1244        };
1245        let gap = jet.point - p;
1246        let r = [gap.dot(jet.du), gap.dot(jet.dv)];
1247        let j = [
1248            [
1249                jet.du.dot(jet.du) + gap.dot(jet.d2u),
1250                jet.du.dot(jet.dv) + gap.dot(jet.duv),
1251            ],
1252            [
1253                jet.du.dot(jet.dv) + gap.dot(jet.duv),
1254                jet.dv.dot(jet.dv) + gap.dot(jet.d2v),
1255            ],
1256        ];
1257        let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
1258        // Singular on a pole, where the column is free and the row is the
1259        // projection's.
1260        if !det.is_finite() || det.abs() <= f64::EPSILON * j[0][0].abs() * j[1][1].abs() {
1261            break;
1262        }
1263        let (du, dv) = (
1264            (r[0] * j[1][1] - r[1] * j[0][1]) / det,
1265            (r[1] * j[0][0] - r[0] * j[1][0]) / det,
1266        );
1267        let (nu, nv) = (u - du, v - dv);
1268        let closer = surface
1269            .point_at(nu, nv, tol)
1270            .is_ok_and(|q| q.distance(p) <= jet.point.distance(p));
1271        if !closer {
1272            break;
1273        }
1274        (u, v) = (nu, nv);
1275        if du.hypot(dv) <= tol.parametric() {
1276            break;
1277        }
1278    }
1279    Ok(Point2::new(u, v))
1280}
1281
1282/// Whether `pcurve` over `range` is a row (`Some(true)`: every point at one
1283/// `v`) or a column (`Some(false)`: at one `u`), read at evenly spaced
1284/// points; `None` where it is neither, or does not move.
1285fn iso_direction(
1286    pcurve: &ogeom_geom::PlanarCurve,
1287    range: (f64, f64),
1288    tol: Tolerances,
1289) -> OgeomResult<Option<bool>> {
1290    const SAMPLES: u32 = 8;
1291    let mut points = Vec::with_capacity(SAMPLES as usize + 1);
1292    for i in 0..=SAMPLES {
1293        let t = range.0 + (range.1 - range.0) * f64::from(i) / f64::from(SAMPLES);
1294        points.push(pcurve.point_at(t, tol)?);
1295    }
1296    let first = points[0];
1297    let spread = |coordinate: fn(Point2) -> f64| {
1298        points
1299            .iter()
1300            .map(|p| (coordinate(*p) - coordinate(first)).abs())
1301            .fold(0.0, f64::max)
1302    };
1303    let (across_u, across_v) = (spread(|p| p.x), spread(|p| p.y));
1304    let still = tol.parametric();
1305    Ok(match (across_u <= still, across_v <= still) {
1306        (false, true) => Some(true),
1307        (true, false) => Some(false),
1308        _ => None,
1309    })
1310}
1311
1312/// The ends of an iso line in the coordinate it runs along, `ends` as its
1313/// end points project onto a chart spanning `(lo, hi)` that way; `at`
1314/// answers that coordinate for the edge's point at a parameter in `range`.
1315///
1316/// On the closure of a closed chart both chart edges image the same point,
1317/// and the projection answers with either: taken wrong, a half circle from
1318/// the seam round the back becomes the segment from the seam to the
1319/// half-way line (the *front* half, mirrored), and the face's ring loses
1320/// its far side. The edge's own interior decides which edge a closure end
1321/// is: the image of a point a little way in from that end lies on the same
1322/// side of the chart. A line whose ends meet is closed and runs the full
1323/// width, oriented by the curve's start.
1324fn run_ends(
1325    ends: (f64, f64),
1326    (lo, hi): (f64, f64),
1327    closed: bool,
1328    range: (f64, f64),
1329    at: impl Fn(f64) -> OgeomResult<f64>,
1330) -> OgeomResult<(f64, f64)> {
1331    let (mut from, mut to) = ends;
1332    let span = hi - lo;
1333    let on_closure = |x: f64| (x - lo).abs() < span * 1e-6 || (x - hi).abs() < span * 1e-6;
1334    let middle = f64::midpoint(lo, hi);
1335    let step = (range.1 - range.0) * 0.02;
1336    if closed && on_closure(from) {
1337        from = if at(range.0 + step)? < middle { lo } else { hi };
1338    }
1339    if closed && on_closure(to) {
1340        to = if at(range.1 - step)? < middle { lo } else { hi };
1341    }
1342    if (from - to).abs() < span * 1e-9 {
1343        (from, to) = (lo, hi);
1344    }
1345    Ok((from, to))
1346}
1347
1348/// The chart edge an iso line on the closure of a closed chart stands on,
1349/// or `None` where `at`, the line's fixed coordinate as its points project,
1350/// is not on the closure or the chart does not close across the line.
1351///
1352/// On the closure both of the chart's edges image the line, and its own
1353/// points, all on the closure, cannot tell which. The face can: the ring
1354/// keeps it on its left, so the line stands on the chart's first edge
1355/// across it where the face lies above it (`face_above`) and on the last
1356/// where the face lies below.
1357fn beside_face(at: &[f64], (lo, hi): (f64, f64), closed: bool, face_above: bool) -> Option<f64> {
1358    let span = hi - lo;
1359    let on_closure = |x: &f64| (x - lo).abs() < span * 1e-6 || (x - hi).abs() < span * 1e-6;
1360    (closed && at.iter().any(on_closure)).then_some(if face_above { lo } else { hi })
1361}
1362
1363/// Exact seam pcurves for a closed patch: the chart's two edges the seam
1364/// runs along (its `u` columns or its `v` rows), each following the seam's
1365/// projected course at the edge's own parameters. `None` where the seam
1366/// does not lie on either pair of edges, or its course is not affine in
1367/// its parameter.
1368fn exact_seam_columns(
1369    curve: &Curve,
1370    range: (f64, f64),
1371    surface: &SurfaceGeometry,
1372    tol: Tolerances,
1373) -> Option<(ogeom_geom::PlanarCurve, ogeom_geom::PlanarCurve)> {
1374    use ogeom_geom::Surface as _;
1375    let ((u0, u1), (v0, v1)) = surface.domain();
1376    const N: usize = 8;
1377    #[allow(clippy::cast_precision_loss)]
1378    let params: Vec<f64> = (0..=N)
1379        .map(|i| range.0 + (range.1 - range.0) * i as f64 / N as f64)
1380        .collect();
1381    let mut chart: Vec<Point2> = Vec::with_capacity(N + 1);
1382    for &t in &params {
1383        let p = curve.point_at(t, tol).ok()?;
1384        let at = crate::measure::project_on_surface(surface, p, 24, tol).ok()?;
1385        chart.push(Point2::new(at.parameters.0, at.parameters.1));
1386    }
1387    let on = |x: f64, a: f64, b: f64| {
1388        let eps = (b - a).abs() * 1e-6;
1389        (x - a).abs() <= eps || (x - b).abs() <= eps
1390    };
1391    // Which pair of chart edges the seam runs on, and the coordinate it
1392    // runs along. A closed run's samples near its far end project to either
1393    // copy of the closure, so a closed direction unwraps like a periodic one.
1394    let (columns, lo, hi, period) = if chart.iter().all(|p| on(p.x, u0, u1)) {
1395        let closed = surface.is_periodic_v() || surface.is_closed_v(tol);
1396        (true, u0, u1, closed.then_some(v1 - v0))
1397    } else if chart.iter().all(|p| on(p.y, v0, v1)) {
1398        let closed = surface.is_periodic_u() || surface.is_closed_u(tol);
1399        (false, v0, v1, closed.then_some(u1 - u0))
1400    } else {
1401        return None;
1402    };
1403    let mut run: Vec<f64> = chart
1404        .iter()
1405        .map(|p| if columns { p.y } else { p.x })
1406        .collect();
1407    if let Some(period) = period {
1408        for i in 1..run.len() {
1409            while run[i] - run[i - 1] > period * 0.5 {
1410                run[i] -= period;
1411            }
1412            while run[i] - run[i - 1] < -period * 0.5 {
1413                run[i] += period;
1414            }
1415        }
1416        // A seam running against the chart's direction unwraps below the
1417        // window from its start; the chart's own copy lies a period up.
1418        let (w0, w1) = if columns { (v0, v1) } else { (u0, u1) };
1419        let eps = period * 1e-9;
1420        let low = run.iter().copied().fold(f64::INFINITY, f64::min);
1421        let high = run.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1422        let shift = if low < w0 - eps {
1423            ((w0 - eps - low) / period).ceil() * period
1424        } else if high > w1 + eps {
1425            -((high - w1 - eps) / period).ceil() * period
1426        } else {
1427            0.0
1428        };
1429        for r in &mut run {
1430            *r += shift;
1431        }
1432    }
1433    let slope = (run[N] - run[0]) / (range.1 - range.0);
1434    let span = (run[N] - run[0]).abs();
1435    if span <= tol.parametric() {
1436        return None;
1437    }
1438    let affine = params
1439        .iter()
1440        .zip(&run)
1441        .all(|(t, r)| (run[0] + slope * (t - range.0) - r).abs() <= span * 1e-7);
1442    if !affine {
1443        return None;
1444    }
1445    let side = |at: f64| -> Option<ogeom_geom::PlanarCurve> {
1446        let (a, b) = if columns {
1447            (Point2::new(at, run[0]), Point2::new(at, run[N]))
1448        } else {
1449            (Point2::new(run[0], at), Point2::new(run[N], at))
1450        };
1451        let knots =
1452            ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1).ok()?;
1453        Some(
1454            ogeom_geom::BSpline2d::new(knots, vec![a, b], tol)
1455                .ok()?
1456                .into(),
1457        )
1458    };
1459    Some((side(hi)?, side(lo)?))
1460}
1461
1462/// The two sides of a seam that runs along an iso line of the new chart, a
1463/// row (`row`) or a column: the chart's last and first line that way, each
1464/// straight between where the edge's ends stand along it. The line is the
1465/// seam's own image, so the sides are exact whatever pace the edge's curve
1466/// keeps along it. `None` where the ends do not stand apart along it, or
1467/// the chart does not close across it.
1468fn seam_lines(
1469    curve: &Curve,
1470    range: (f64, f64),
1471    surface: &SurfaceGeometry,
1472    row: bool,
1473    tol: Tolerances,
1474) -> OgeomResult<Option<(ogeom_geom::PlanarCurve, ogeom_geom::PlanarCurve)>> {
1475    use ogeom_geom::Surface as _;
1476    let ((u0, u1), (v0, v1)) = surface.domain();
1477    let (across, along, closes_across, closes_along) = if row {
1478        (
1479            (v0, v1),
1480            (u0, u1),
1481            surface.is_periodic_v() || surface.is_closed_v(tol),
1482            surface.is_periodic_u() || surface.is_closed_u(tol),
1483        )
1484    } else {
1485        (
1486            (u0, u1),
1487            (v0, v1),
1488            surface.is_periodic_u() || surface.is_closed_u(tol),
1489            surface.is_periodic_v() || surface.is_closed_v(tol),
1490        )
1491    };
1492    if !closes_across {
1493        return Ok(None);
1494    }
1495    // The seam stands on the closure across it, as `exact_seam_columns`
1496    // reads a seam's samples; a chart whose closure lies elsewhere (a
1497    // surface restated about a turned frame) has its seam fitted.
1498    let middle = foot(
1499        surface,
1500        curve.point_at(f64::midpoint(range.0, range.1), tol)?,
1501        tol,
1502    )?;
1503    let fixed = if row { middle.y } else { middle.x };
1504    let reach = (across.1 - across.0).abs() * 1e-6;
1505    if (fixed - across.0).abs() > reach && (fixed - across.1).abs() > reach {
1506        return Ok(None);
1507    }
1508    let free = |t: f64| -> OgeomResult<f64> {
1509        let at = foot(surface, curve.point_at(t, tol)?, tol)?;
1510        Ok(if row { at.x } else { at.y })
1511    };
1512    let (a, b) = run_ends(
1513        (free(range.0)?, free(range.1)?),
1514        along,
1515        closes_along,
1516        range,
1517        free,
1518    )?;
1519    if (b - a).abs() <= tol.parametric() {
1520        return Ok(None);
1521    }
1522    let side = |at: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1523        let (from, to) = if row {
1524            (Point2::new(a, at), Point2::new(b, at))
1525        } else {
1526            (Point2::new(at, a), Point2::new(at, b))
1527        };
1528        let knots = ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
1529        Ok(ogeom_geom::BSpline2d::new(knots, vec![from, to], tol)?.into())
1530    };
1531    Ok(Some((side(across.1)?, side(across.0)?)))
1532}
1533
1534/// The two seam-side pcurves of a closed patch: the chart's first and last
1535/// `u` columns, or its first and last `v` rows where the seam runs along
1536/// the closure in `v` (a torus's tube seam), each fitted at the edge's own
1537/// parameters. Which closure the seam runs on is read at its middle, where
1538/// it stands on one of them and on no pole.
1539fn seam_pcurves(
1540    curve: &Curve,
1541    range: (f64, f64),
1542    surface: &SurfaceGeometry,
1543    target: f64,
1544    tol: Tolerances,
1545) -> OgeomResult<(ogeom_geom::PlanarCurve, ogeom_geom::PlanarCurve)> {
1546    use ogeom_geom::Surface as _;
1547    let ((u0, u1), (v0, v1)) = surface.domain();
1548    let middle = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
1549    let (u, v) = crate::measure::project_on_surface(surface, middle, 24, tol)?.parameters;
1550    let off = |x: f64, a: f64, b: f64| (x - a).abs().min((x - b).abs()) / (b - a).abs();
1551    let on_rows =
1552        (surface.is_periodic_v() || surface.is_closed_v(tol)) && off(v, v0, v1) < off(u, u0, u1);
1553    let (column, lo, hi) = if on_rows {
1554        (false, v0, v1)
1555    } else {
1556        (true, u0, u1)
1557    };
1558    let forward = fit_pcurve(
1559        curve,
1560        range,
1561        surface,
1562        Some((column, hi)),
1563        (None, None),
1564        target,
1565        tol,
1566    )?;
1567    let reversed = fit_pcurve(
1568        curve,
1569        range,
1570        surface,
1571        Some((column, lo)),
1572        (None, None),
1573        target,
1574        tol,
1575    )?;
1576    Ok((forward, reversed))
1577}
1578
1579/// The pole row of a degenerate edge, restated in the new chart; `pole` is
1580/// its vertex where the new surface holds it. The row runs with the new
1581/// chart's `u` as the old one's does, or against it where the face's
1582/// rings are walked back.
1583#[expect(
1584    clippy::too_many_arguments,
1585    reason = "the old and new charts of one edge"
1586)]
1587fn degenerate_row(
1588    model: &Model,
1589    data: &ogeom_topo::EdgeData,
1590    old_surface: ogeom_topo::SurfaceId,
1591    edge: &Shape,
1592    old: &SurfaceGeometry,
1593    new: &SurfaceGeometry,
1594    pole: Point,
1595    walked_back: bool,
1596    tol: Tolerances,
1597) -> OgeomResult<ogeom_geom::Line2d> {
1598    // Which end of the old chart the pole row sat at decides which end of
1599    // the new chart it sits at: conversion preserves ends.
1600    let Some(EdgeRepr::PCurve {
1601        curve,
1602        range: row_range,
1603        ..
1604    }) = data.pcurve_for(old_surface, edge.location())
1605    else {
1606        ogeom_bail!(Construction, "a degenerate edge has no pcurve to restate");
1607    };
1608    let row_range = *row_range;
1609    let Some(row) = model.geometry().pcurve(*curve) else {
1610        ogeom_bail!(Dangling, "pcurve is not in this model");
1611    };
1612    let (lo, hi) = row.domain();
1613    let at = row.point_at(f64::midpoint(lo, hi), tol)?;
1614    let v_old = at.y;
1615    let ((ua_old, ub_old), (va_old, vb_old)) = old.domain();
1616    let ((nu0, nu1), (nv0, nv1)) = new.domain();
1617    // The row where the new surface reaches the pole: a profile running
1618    // past its pole (a whole circle revolved) puts the pole inside the
1619    // chart, not at an end. Where the rows never reach it, conversion
1620    // preserves ends, and the old row's nearer end is the new one's.
1621    let v_new =
1622        pole_row(new, pole, tol).unwrap_or(if (v_old - va_old).abs() <= (v_old - vb_old).abs() {
1623            nv0
1624        } else {
1625            nv1
1626        });
1627    // A pole row spanning only part of the old chart's width (a face cut
1628    // from a ball) spans the columns its ends stand on: each read off the
1629    // old surface's middle row, away from the pole where every column
1630    // meets, and imaged onto the new surface, an end on the chart's
1631    // closure placed by a point a little way in, as an iso line's ends are.
1632    let (row_u0, row_u1) = (
1633        row.point_at(row_range.0, tol)?.x,
1634        row.point_at(row_range.1, tol)?.x,
1635    );
1636    let partial = (row_u1 - row_u0).abs() < (ub_old - ua_old).abs() - tol.parametric();
1637    let middle = f64::midpoint(va_old, vb_old);
1638    if partial && middle.is_finite() {
1639        let column = |t: f64| -> OgeomResult<f64> {
1640            let u = row.point_at(t, tol)?.x;
1641            let p = old.point_at(u, middle, tol)?;
1642            Ok(foot(new, p, tol)?.x)
1643        };
1644        let closed_u = new.is_periodic_u() || new.is_closed_u(tol);
1645        let (a, b) = run_ends(
1646            (column(row_range.0)?, column(row_range.1)?),
1647            (nu0, nu1),
1648            closed_u,
1649            row_range,
1650            column,
1651        )?;
1652        return ogeom_geom::Line2d::segment(Point2::new(a, v_new), Point2::new(b, v_new), tol);
1653    }
1654    let (from, to) = (Point2::new(nu0, v_new), Point2::new(nu1, v_new));
1655    if walked_back {
1656        ogeom_geom::Line2d::segment(to, from, tol)
1657    } else {
1658        ogeom_geom::Line2d::segment(from, to, tol)
1659    }
1660}
1661
1662/// The `v` at which a surface's middle column reaches `pole`, to the
1663/// confusion distance, or `None` where it does not come that close: the
1664/// column sampled, the nearest sample narrowed by golden sections.
1665fn pole_row(surface: &SurfaceGeometry, pole: Point, tol: Tolerances) -> Option<f64> {
1666    const SAMPLES: u32 = 256;
1667    let ((u0, u1), (v0, v1)) = surface.domain();
1668    let u = f64::midpoint(u0, u1);
1669    let gap = |v: f64| {
1670        surface
1671            .point_at(u, v, tol)
1672            .map_or(f64::INFINITY, |p| p.distance(pole))
1673    };
1674    let at = |k: u32| v0 + (v1 - v0) * f64::from(k) / f64::from(SAMPLES);
1675    let best = (0..=SAMPLES).min_by(|&a, &b| gap(at(a)).total_cmp(&gap(at(b))))?;
1676    let (mut lo, mut hi) = (at(best.saturating_sub(1)), at((best + 1).min(SAMPLES)));
1677    let golden = (5.0_f64.sqrt() - 1.0) / 2.0;
1678    for _ in 0..80 {
1679        let (a, b) = (hi - golden * (hi - lo), lo + golden * (hi - lo));
1680        if gap(a) <= gap(b) {
1681            hi = b;
1682        } else {
1683            lo = a;
1684        }
1685    }
1686    let v = f64::midpoint(lo, hi);
1687    (gap(v) <= tol.confusion()).then_some(v)
1688}
1689
1690/// Whether a face's new surface has its natural normal against the old
1691/// surface's carried through the placement, read in the middle of the
1692/// face's own chart window; `None` where the surfaces cannot be read there.
1693fn turned_by_rebuild(
1694    old: &SurfaceGeometry,
1695    placement: &ogeom_math::Transform,
1696    window: ChartWindow,
1697    new: &SurfaceGeometry,
1698    tol: Tolerances,
1699) -> Option<bool> {
1700    let ((u0, u1), (v0, v1)) = window;
1701    if !(u0.is_finite() && u1.is_finite() && v0.is_finite() && v1.is_finite()) {
1702        return None;
1703    }
1704    // Off the exact middle: a revolution's middle can sit on its axis.
1705    let (u, v) = (u0 + (u1 - u0) * 0.43, v0 + (v1 - v0) * 0.57);
1706    let at = placement.apply(old.point_at(u, v, tol).ok()?);
1707    let was = placement.apply_vector(old.normal_at(u, v, tol).ok()?.vector());
1708    let foot = crate::measure::project_on_surface(new, at, 16, tol).ok()?;
1709    let (nu, nv) = foot.parameters;
1710    let now = new.normal_at(nu, nv, tol).ok()?.vector();
1711    Some(was.dot(now) < 0.0)
1712}
1713
1714/// A revolution's profile held to the rows the face uses, exactly, for
1715/// converting: a profile running on past a pole (a whole circle turned)
1716/// would put the pole inside the converted chart, where no row of it is an
1717/// end. The turn stays whole. Any other surface is itself.
1718fn profile_held(
1719    surface: &SurfaceGeometry,
1720    window: ChartWindow,
1721    tol: Tolerances,
1722) -> SurfaceGeometry {
1723    let SurfaceGeometry::Revolution(_) = surface else {
1724        return surface.clone();
1725    };
1726    let ((du0, du1), (dv0, dv1)) = surface.domain();
1727    let (_, (v0, v1)) = window;
1728    if !(v0.is_finite() && v1.is_finite()) || v1 - v0 <= tol.parametric() {
1729        return surface.clone();
1730    }
1731    ogeom_geom::TrimmedSurface::new(surface.clone(), (du0, du1), (v0.max(dv0), v1.min(dv1)), tol)
1732        .map_or_else(
1733            |_| surface.clone(),
1734            |t| SurfaceGeometry::Trimmed(Box::new(t)),
1735        )
1736}
1737
1738/// The surface shrunk to the face's own chart region, with a margin.
1739///
1740/// The face's pcurves say which part of the surface the face actually uses;
1741/// converting the whole declared domain would spend the patch's parameter
1742/// range on empty plane. Kinds whose windows are structural (a sphere's, a
1743/// torus's, the closed direction of a cylinder) keep them.
1744fn bounded_to_face(
1745    model: &Model,
1746    face: &Shape,
1747    surface_id: ogeom_topo::SurfaceId,
1748    placed: &SurfaceGeometry,
1749    stretch: f64,
1750    tol: Tolerances,
1751) -> OgeomResult<(SurfaceGeometry, ChartWindow)> {
1752    let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
1753    let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
1754    for edge in explore(model, face, Filter::OfType(ShapeType::Edge))? {
1755        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1756            continue;
1757        };
1758        let mut widen = |pc: ogeom_topo::PCurveId, range: (f64, f64)| -> OgeomResult<()> {
1759            let Some(pcurve) = model.geometry().pcurve(pc) else {
1760                return Ok(());
1761            };
1762            for i in 0..=8 {
1763                #[allow(clippy::cast_precision_loss)]
1764                let t = range.0 + (range.1 - range.0) * f64::from(i) / 8.0;
1765                let p = pcurve.point_at(t, tol)?;
1766                u0 = u0.min(p.x);
1767                u1 = u1.max(p.x);
1768                v0 = v0.min(p.y);
1769                v1 = v1.max(p.y);
1770            }
1771            Ok(())
1772        };
1773        match data.pcurve_for(surface_id, edge.location()) {
1774            Some(EdgeRepr::PCurve { curve, range, .. }) => widen(*curve, *range)?,
1775            Some(EdgeRepr::Seam {
1776                forward,
1777                reversed,
1778                range,
1779                ..
1780            }) => {
1781                widen(*forward, *range)?;
1782                widen(*reversed, *range)?;
1783            }
1784            _ => {}
1785        }
1786    }
1787    if !u0.is_finite() || u1 - u0 <= tol.confusion() || v1 - v0 <= tol.confusion() {
1788        return Ok((placed.clone(), placed.domain()));
1789    }
1790    // The window was read off the *old* pcurves, in the old chart's units;
1791    // a placement with scale stretches the placed chart's length-like
1792    // directions, and the trim must stretch with them. Angle directions
1793    // never stretch.
1794    let (su, sv) = match placed {
1795        SurfaceGeometry::Plane(_) => (stretch, stretch),
1796        SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_) => (1.0, stretch),
1797        _ => (1.0, 1.0),
1798    };
1799    let (u0, u1, v0, v1) = (u0 * su, u1 * su, v0 * sv, v1 * sv);
1800    let margin = ((u1 - u0) + (v1 - v0)) * 0.05 + tol.confusion();
1801    let bounded: SurfaceGeometry = match placed {
1802        SurfaceGeometry::Plane(p) => ogeom_geom::PlaneSurface::over(
1803            p.plane(),
1804            (u0 - margin, u1 + margin),
1805            (v0 - margin, v1 + margin),
1806        )?
1807        .into(),
1808        SurfaceGeometry::Cylinder(c) => {
1809            ogeom_geom::CylinderSurface::new(c.cylinder(), (v0 - margin, v1 + margin))?.into()
1810        }
1811        SurfaceGeometry::Cone(c) => {
1812            // The margin stops at the apex: past it lies the other nappe,
1813            // which the face never reaches and a patch over it would take in.
1814            let cone = c.cone();
1815            let slope = cone.half_angle().tan();
1816            let (mut low, mut high) = (v0 - margin, v1 + margin);
1817            if slope.abs() > tol.angular() {
1818                let apex = -cone.reference_radius() / slope;
1819                if v0 >= apex - tol.confusion() {
1820                    low = low.max(apex);
1821                }
1822                if v1 <= apex + tol.confusion() {
1823                    high = high.min(apex);
1824                }
1825            }
1826            ogeom_geom::ConeSurface::new(cone, (low, high))?.into()
1827        }
1828        // An offset over a plane's or drum's unbounded chart has no
1829        // extent to fit or convert over; held to the face's window, it has.
1830        SurfaceGeometry::Offset(o) if !finite(o.basis().domain()) => {
1831            let ((du0, du1), (dv0, dv1)) = placed.domain();
1832            ogeom_geom::TrimmedSurface::new(
1833                placed.clone(),
1834                ((u0 - margin).max(du0), (u1 + margin).min(du1)),
1835                ((v0 - margin).max(dv0), (v1 + margin).min(dv1)),
1836                tol,
1837            )
1838            .map_or_else(
1839                |_| placed.clone(),
1840                |t| SurfaceGeometry::Trimmed(Box::new(t)),
1841            )
1842        }
1843        // A revolution keeps its whole surface for the restatements that
1844        // recognise it, and reports the rows the face uses, which is what
1845        // its conversion is held to.
1846        SurfaceGeometry::Revolution(_) => {
1847            let ((du0, du1), _) = placed.domain();
1848            return Ok((placed.clone(), ((du0, du1), (v0, v1))));
1849        }
1850        other => other.clone(),
1851    };
1852    let window = bounded.domain();
1853    Ok((bounded, window))
1854}
1855
1856fn finite(domain: ChartWindow) -> bool {
1857    [domain.0.0, domain.0.1, domain.1.0, domain.1.1]
1858        .iter()
1859        .all(|x| x.is_finite() && x.abs() < 1e6)
1860}
1861
1862/// A B-spline patch with its control points carried through an affine map.
1863fn transformed_patch(
1864    patch: &ogeom_geom::BSplineSurface,
1865    transform: &GeneralTransform,
1866) -> OgeomResult<ogeom_geom::BSplineSurface> {
1867    let grid = patch.grid();
1868    let mapped = grid.map(|c| {
1869        let p = Point::from_vector(c.scaled.to_vector() / c.weight);
1870        let moved = transform.apply(p);
1871        Weighted {
1872            scaled: Point::from_vector(moved.to_vector() * c.weight),
1873            weight: c.weight,
1874        }
1875    });
1876    ogeom_geom::BSplineSurface::rational(patch.u_knots().clone(), patch.v_knots().clone(), mapped)
1877}
1878
1879/// The rebuilt vertices, by the old vertex node they stand for.
1880///
1881/// One old vertex placed at two points that coincide within its tolerance
1882/// (a profile's end on the axis of a turn, under the profile's placement
1883/// and under the turn's) is one new vertex, so every edge that meets it
1884/// there ends on the same node.
1885#[derive(Default)]
1886struct NewVertices(FastMap<TShapeId, Vec<(Point, Shape)>>);
1887
1888impl NewVertices {
1889    /// The new vertex for the old `node` placed at `at`, made on first ask.
1890    fn at(
1891        &mut self,
1892        model: &mut Model,
1893        node: TShapeId,
1894        at: Point,
1895        reach: f64,
1896        tol: Tolerances,
1897    ) -> Shape {
1898        if let Some(found) = self.find(node, at, reach, tol) {
1899            return found;
1900        }
1901        let fresh = make_vertex(model, at).shape;
1902        self.0.entry(node).or_default().push((at, fresh.clone()));
1903        fresh
1904    }
1905
1906    /// The new vertex already made for the old `node` placed at `at`.
1907    fn find(&self, node: TShapeId, at: Point, reach: f64, tol: Tolerances) -> Option<Shape> {
1908        let reach = reach.max(tol.confusion());
1909        self.0
1910            .get(&node)?
1911            .iter()
1912            .find(|(p, _)| p.distance(at) <= reach)
1913            .map(|(_, found)| found.clone())
1914    }
1915}
1916
1917/// Record `input` as modified into `image`, once: a sub-shape reached along
1918/// several paths in the walk has one image, not one per path.
1919fn record(history: &mut History, input: &Shape, image: &Shape) {
1920    if !history.modified(input).iter().any(|s| s.is_same(image)) {
1921        history.modify(input, image.clone());
1922    }
1923}
1924
1925/// Record each vertex occurrence bounding the old `edge` as modified into
1926/// the new vertex made for it.
1927fn record_vertices(
1928    model: &Model,
1929    edge: &Shape,
1930    map: &dyn Fn(Point) -> Point,
1931    vertices: &NewVertices,
1932    history: &mut History,
1933    tol: Tolerances,
1934) -> OgeomResult<()> {
1935    for vertex in model.children_of(edge)? {
1936        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
1937            ogeom_bail!(Construction, "vertex node holds no vertex data");
1938        };
1939        let at = map(vertex.transform(model.datums())?.apply(data.point));
1940        if let Some(twin) = vertices.find(vertex.node(), at, data.tolerance.get(), tol) {
1941            record(history, &vertex, &twin);
1942        }
1943    }
1944    Ok(())
1945}
1946
1947/// A rigid placement quantized for deduplication keys.
1948fn placement_bits(t: &ogeom_math::Transform) -> [u64; 3] {
1949    let p = t.apply(Point::new(0.123_456_789, 9.87, -3.21));
1950    point_bits(p)
1951}
1952
1953fn point_bits(p: Point) -> [u64; 3] {
1954    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
1955}
1956
1957#[cfg(test)]
1958#[allow(clippy::unwrap_used, clippy::expect_used)]
1959mod tests {
1960    use super::*;
1961    use crate::{make_box, make_cylinder, volume_properties};
1962    use ogeom_math::Frame;
1963    use ogeom_mesh::Deflection;
1964
1965    const T: Tolerances = Tolerances::millimetres();
1966
1967    fn fine() -> Deflection {
1968        Deflection {
1969            chord: 1e-3,
1970            ..Deflection::default()
1971        }
1972    }
1973
1974    /// A prism's near cap is its profile turned round. Baked, each face
1975    /// keeps its rings as it stored them and takes its sense back on top,
1976    /// so every edge is still walked once each way.
1977    #[test]
1978    fn a_baked_prism_walks_each_edge_once_each_way() {
1979        let mut model = Model::new();
1980        let block = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
1981            .unwrap()
1982            .shape;
1983        let face = ogeom_topo::explore_unique(&model, &block, ShapeType::Face).unwrap()[0].clone();
1984        let prism = crate::make_prism(&mut model, &face, ogeom_math::Vector::new(0.0, 0.0, 5.0), T)
1985            .unwrap()
1986            .shape;
1987        let baked = baked_shape(&mut model, &prism, T).unwrap().shape;
1988        assert_eq!(crate::check::edges_walked_one_way(&model, &baked), 0);
1989    }
1990
1991    #[test]
1992    fn a_box_converts_to_nurbs_and_measures_the_same() {
1993        let mut model = Model::new();
1994        let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1995        let converted = to_nurbs(&mut model, &solid.shape, T).unwrap();
1996
1997        // Every surface is a spline after conversion.
1998        for face in explore(&model, &converted.shape, Filter::OfType(ShapeType::Face)).unwrap() {
1999            let NodeData::Face(data) = model.node(&face).unwrap().data() else {
2000                panic!("face data");
2001            };
2002            assert!(matches!(
2003                model.geometry().surface(data.surface),
2004                Some(SurfaceGeometry::BSpline(_))
2005            ));
2006        }
2007        let props = volume_properties(&model, &converted.shape, fine(), T).unwrap();
2008        assert!((props.mass - 24.0).abs() < 1e-6, "volume {}", props.mass);
2009        assert!(
2010            crate::check(&model, &converted.shape, T)
2011                .unwrap()
2012                .is_valid(),
2013            "{}",
2014            crate::check(&model, &converted.shape, T).unwrap()
2015        );
2016        assert_eq!(
2017            converted.history.modified(&solid.shape),
2018            std::slice::from_ref(&converted.shape)
2019        );
2020    }
2021
2022    #[test]
2023    fn a_cylinder_converts_seams_poles_and_all() {
2024        let mut model = Model::new();
2025        let solid = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
2026        let converted = to_nurbs(&mut model, &solid.shape, T).unwrap();
2027        let exact = core::f64::consts::PI * 4.0 * 5.0;
2028        let props = volume_properties(&model, &converted.shape, fine(), T).unwrap();
2029        assert!(
2030            (props.mass - exact).abs() < exact * 5e-3,
2031            "volume {} against {exact}",
2032            props.mass
2033        );
2034    }
2035
2036    #[test]
2037    fn a_uniform_scale_is_a_placement_and_leaves_the_surfaces_analytic() {
2038        // The distinction this module is built on, stated as a test. A
2039        // similarity carries a plane to a plane, so the shape keeps saying
2040        // what it is; converting it would leave six patches that happen to be
2041        // flat, and nothing downstream that asks about coincidence can read
2042        // flatness out of a patch.
2043        let mut model = Model::new();
2044        let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2045        let scale = GeneralTransform::new(
2046            ogeom_math::Matrix3 {
2047                rows: [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]],
2048            },
2049            ogeom_math::Vector::ZERO,
2050        );
2051
2052        let scaled = general_transformed_shape(&mut model, &solid.shape, &scale, T).unwrap();
2053
2054        for face in explore(&model, &scaled.shape, Filter::OfType(ShapeType::Face)).unwrap() {
2055            let NodeData::Face(data) = model.node(&face).unwrap().data() else {
2056                panic!("face data");
2057            };
2058            assert!(
2059                matches!(
2060                    model.geometry().surface(data.surface),
2061                    Some(SurfaceGeometry::Plane(_))
2062                ),
2063                "a scaled box's face stopped being a plane"
2064            );
2065        }
2066        let props = volume_properties(&model, &scaled.shape, fine(), T).unwrap();
2067        assert!((props.mass - 192.0).abs() < 1e-6, "scaled {}", props.mass);
2068    }
2069
2070    #[test]
2071    fn a_shear_preserves_volume_and_a_stretch_scales_it() {
2072        let mut model = Model::new();
2073        let solid = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2074
2075        // A unit-determinant shear: x' = x + 0.5 y.
2076        let shear = GeneralTransform::new(
2077            ogeom_math::Matrix3 {
2078                rows: [[1.0, 0.5, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2079            },
2080            ogeom_math::Vector::ZERO,
2081        );
2082        let sheared = general_transformed_shape(&mut model, &solid.shape, &shear, T).unwrap();
2083        let props = volume_properties(&model, &sheared.shape, fine(), T).unwrap();
2084        assert!((props.mass - 24.0).abs() < 1e-6, "sheared {}", props.mass);
2085
2086        // An uneven stretch doubles x: volume doubles.
2087        let stretch = GeneralTransform::new(
2088            ogeom_math::Matrix3 {
2089                rows: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2090            },
2091            ogeom_math::Vector::ZERO,
2092        );
2093        let stretched = general_transformed_shape(&mut model, &solid.shape, &stretch, T).unwrap();
2094        let props = volume_properties(&model, &stretched.shape, fine(), T).unwrap();
2095        assert!((props.mass - 48.0).abs() < 1e-6, "stretched {}", props.mass);
2096    }
2097
2098    #[test]
2099    fn a_sheared_cylinder_still_encloses_its_volume() {
2100        // The case placements cannot express: the circular rims become
2101        // ellipses, and the volume is invariant under the unit-det shear.
2102        let mut model = Model::new();
2103        let solid = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
2104        let shear = GeneralTransform::new(
2105            ogeom_math::Matrix3 {
2106                rows: [[1.0, 0.0, 0.4], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2107            },
2108            ogeom_math::Vector::ZERO,
2109        );
2110        let sheared = general_transformed_shape(&mut model, &solid.shape, &shear, T).unwrap();
2111        let exact = core::f64::consts::PI * 4.0 * 5.0;
2112        let props = volume_properties(&model, &sheared.shape, fine(), T).unwrap();
2113        assert!(
2114            (props.mass - exact).abs() < exact * 5e-3,
2115            "sheared cylinder {} against {exact}",
2116            props.mass
2117        );
2118    }
2119
2120    /// A box, a cylinder and a sphere (whose poles are degenerate edges)
2121    /// under a shear and an uneven stretch: every face, wire, edge and
2122    /// vertex of the input traces to exactly one sub-shape of its kind in
2123    /// the result and reads as affected, and each vertex's image stands
2124    /// where the map carries the old point.
2125    #[test]
2126    fn every_sub_shape_traces_to_its_twin_under_an_affine_map() {
2127        let shear = GeneralTransform::new(
2128            ogeom_math::Matrix3 {
2129                rows: [[1.0, 0.0, 0.4], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2130            },
2131            ogeom_math::Vector::new(1.0, 2.0, 3.0),
2132        );
2133        let stretch = GeneralTransform::new(
2134            ogeom_math::Matrix3 {
2135                rows: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.5]],
2136            },
2137            ogeom_math::Vector::ZERO,
2138        );
2139        let mut model = Model::new();
2140        let block = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
2141            .unwrap()
2142            .shape;
2143        let drum = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
2144            .unwrap()
2145            .shape;
2146        let ball = crate::make_sphere(&mut model, Frame::WORLD, 2.0, T)
2147            .unwrap()
2148            .shape;
2149        for solid in [&block, &drum, &ball] {
2150            for transform in [&shear, &stretch] {
2151                let built = general_transformed_shape(&mut model, solid, transform, T).unwrap();
2152                assert!(built.history.trace(solid)[0].is_same(&built.shape));
2153                for kind in [
2154                    ShapeType::Face,
2155                    ShapeType::Wire,
2156                    ShapeType::Edge,
2157                    ShapeType::Vertex,
2158                ] {
2159                    let parts = ogeom_topo::explore_unique(&model, solid, kind).unwrap();
2160                    let result = ogeom_topo::explore_unique(&model, &built.shape, kind).unwrap();
2161                    assert_eq!(parts.len(), result.len(), "{kind:?}");
2162                    for part in &parts {
2163                        assert!(built.history.is_affected(part), "{kind:?}");
2164                        let images = built.history.trace(part);
2165                        assert_eq!(images.len(), 1, "{kind:?}");
2166                        let found: Vec<&Shape> =
2167                            result.iter().filter(|r| r.is_same(&images[0])).collect();
2168                        assert_eq!(found.len(), 1, "{kind:?}");
2169                        if kind == ShapeType::Vertex {
2170                            let point = |v: &Shape| {
2171                                let data = model.node(v).unwrap().data().as_vertex().unwrap();
2172                                v.transform(model.datums()).unwrap().apply(data.point)
2173                            };
2174                            let want = transform.apply(point(part));
2175                            assert!(point(found[0]).distance(want) < 1e-9);
2176                        }
2177                    }
2178                }
2179            }
2180        }
2181    }
2182}