Skip to main content

ogeom_heal/
divide.rs

1//! Dividing a shape into smaller pieces: faces cut along lines of their own
2//! parameters, edges cut at parameters of their own.
3//!
4//! Every piece keeps the curve or surface it was cut from, and so its
5//! parameters: nothing is refitted, and a pcurve that held on the whole
6//! holds on each piece. A cut face's new boundary is the surface's own
7//! iso-curve, exact for every surface whose iso-curves have a closed form
8//! (planes, drums, cones, balls, tori, extrusions, revolutions and
9//! splines).
10//!
11//! The divisions on top pick where to cut: at the knots where a spline is
12//! less smooth than asked ([`divide_by_continuity`]), wherever a face turns
13//! further than an angle ([`divide_by_angle`]), until no face is larger than
14//! an area ([`divide_by_area`]), and at every knot, each piece's geometry
15//! then restated as the single Bézier span it covers ([`to_bezier`]).
16
17use ogeom_core::{FastMap, FastSet};
18
19use ogeom_algo::{Built, History, edge_vertices, surface_properties};
20use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
21use ogeom_geom::{
22    BSpline2d, CircleCurve, Continuity, Curve, Curve2d as _, Curve3d as _, LineCurve, PlanarCurve,
23    Surface as _, SurfaceGeometry, Transformable as _,
24};
25use ogeom_math::{Axis, Circle, Direction, Frame, KnotVector, Point, Point2, Transform};
26use ogeom_mesh::Deflection;
27use ogeom_topo::{
28    EdgeData, EdgeRepr, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType,
29    SurfaceId, TShapeId, VertexData, explore, explore_unique,
30};
31
32use crate::Reshape;
33
34/// A line of a face's parameters: `u = at` or `v = at`.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum IsoLine {
37    /// The line `u = at`, running along `v`.
38    U(f64),
39    /// The line `v = at`, running along `u`.
40    V(f64),
41}
42
43impl IsoLine {
44    const fn at(self) -> f64 {
45        match self {
46            Self::U(c) | Self::V(c) => c,
47        }
48    }
49
50    /// The parameter the line holds fixed, read off a chart point.
51    const fn fixed(self, p: Point2) -> f64 {
52        match self {
53            Self::U(_) => p.x,
54            Self::V(_) => p.y,
55        }
56    }
57
58    /// The parameter the line runs along.
59    const fn free(self, p: Point2) -> f64 {
60        match self {
61            Self::U(_) => p.y,
62            Self::V(_) => p.x,
63        }
64    }
65
66    const fn point(self, free: f64) -> Point2 {
67        match self {
68            Self::U(c) => Point2::new(c, free),
69            Self::V(c) => Point2::new(free, c),
70        }
71    }
72}
73
74/// Cut `face` of `shape` in two or more along `line`, and rebuild `shape`
75/// around the pieces. The edges the line crosses are cut where it crosses
76/// them, in every face that holds them.
77///
78/// # Errors
79///
80/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
81/// the line does not cross the face, runs along one of its edges, or the
82/// surface has no closed-form iso-curve.
83pub fn divide_face(
84    model: &mut Model,
85    shape: &Shape,
86    face: &Shape,
87    line: IsoLine,
88    tol: Tolerances,
89) -> OgeomResult<Built> {
90    let Some(reshape) = cut_face(model, face, line, tol)? else {
91        ogeom_bail!(Construction, "the line {line:?} does not cross the face");
92    };
93    reshape.apply(model, shape)
94}
95
96/// Cut every edge and face of `shape` where its spline is less smooth than
97/// `at_least`, so each piece is at least that smooth throughout. A knot is
98/// judged by its multiplicity, so G1 and G2 ask what C1 and C2 ask.
99///
100/// Edges are cut at their curves' knots; faces along their surfaces' knot
101/// lines, for a spline, an extrusion or revolution of a spline, and a
102/// trimmed or offset surface over one.
103///
104/// # Errors
105///
106/// As [`divide_face`], for a cut that cannot be made.
107pub fn divide_by_continuity(
108    model: &mut Model,
109    shape: &Shape,
110    at_least: Continuity,
111    tol: Tolerances,
112) -> OgeomResult<Built> {
113    let order = match at_least {
114        Continuity::C0 => return Ok(Built::new(shape.clone(), History::identity())),
115        Continuity::G1 | Continuity::C1 => 1,
116        Continuity::G2 | Continuity::C2 => 2,
117        Continuity::CInfinity => usize::MAX,
118    };
119    let start = placed_baked(model, shape, tol)?;
120    let edges = divide_edges(model, &start.shape, order, tol)?;
121    let faces = divide_faces(model, &edges.shape, tol, |_, _, surface, _| {
122        Ok(knot_lines(surface, order))
123    })?;
124    Ok(Built::new(
125        faces.shape,
126        start.history.then(&edges.history).then(&faces.history),
127    ))
128}
129
130/// Cut every face of `shape` whose angular parameters (a drum's, cone's,
131/// ball's, torus's or revolution's turn) sweep more than `max_angle`, into
132/// equal pieces sweeping no more than it. A closed face comes apart at its
133/// seam into open ones.
134///
135/// # Errors
136///
137/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
138/// `max_angle` is not a positive angle, and as [`divide_face`].
139pub fn divide_by_angle(
140    model: &mut Model,
141    shape: &Shape,
142    max_angle: f64,
143    tol: Tolerances,
144) -> OgeomResult<Built> {
145    if !max_angle.is_finite() || max_angle <= tol.angular() {
146        ogeom_bail!(Construction, "{max_angle} is not an angle to divide by");
147    }
148    let start = placed_baked(model, shape, tol)?;
149    let faces = divide_faces(model, &start.shape, tol, |_, _, surface, bounds| {
150        let (u_turns, v_turns) = angular(surface);
151        let mut lines = Vec::new();
152        for (turns, (lo, hi), iso) in [
153            (u_turns, bounds.0, IsoLine::U as fn(f64) -> IsoLine),
154            (v_turns, bounds.1, IsoLine::V),
155        ] {
156            let span = hi - lo;
157            if turns && span > max_angle * (1.0 + 1e-9) {
158                let pieces = (span / max_angle - 1e-6).ceil();
159                lines.push(iso(lo + span / pieces));
160            }
161        }
162        Ok(lines)
163    })?;
164    Ok(Built::new(faces.shape, start.history.then(&faces.history)))
165}
166
167/// Cut every face of `shape` larger than `max_area` in half, across its
168/// longer extent, until none is.
169///
170/// # Errors
171///
172/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
173/// `max_area` is not a positive area, and as [`divide_face`].
174pub fn divide_by_area(
175    model: &mut Model,
176    shape: &Shape,
177    max_area: f64,
178    tol: Tolerances,
179) -> OgeomResult<Built> {
180    if !max_area.is_finite() || max_area <= tol.confusion() * tol.confusion() {
181        ogeom_bail!(Construction, "{max_area} is not an area to divide by");
182    }
183    let start = placed_baked(model, shape, tol)?;
184    let faces = divide_faces(model, &start.shape, tol, |model, face, surface, bounds| {
185        let area = surface_properties(model, face, Deflection::default(), tol)?.mass;
186        if area <= max_area {
187            return Ok(Vec::new());
188        }
189        let ((u0, u1), (v0, v1)) = bounds;
190        let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
191        // How long the face runs each way, along the lines through its
192        // middle.
193        let length = |from: (f64, f64), to: (f64, f64)| -> OgeomResult<f64> {
194            let mut total = 0.0;
195            let mut last = surface.point_at(from.0, from.1, tol)?;
196            for i in 1..=16 {
197                let s = f64::from(i) / 16.0;
198                let p = surface.point_at(
199                    from.0 + (to.0 - from.0) * s,
200                    from.1 + (to.1 - from.1) * s,
201                    tol,
202                )?;
203                total += p.distance(last);
204                last = p;
205            }
206            Ok(total)
207        };
208        let across_u = length((u0, vm), (u1, vm))?;
209        let across_v = length((um, v0), (um, v1))?;
210        Ok(if across_u >= across_v {
211            vec![IsoLine::U(um), IsoLine::V(vm)]
212        } else {
213            vec![IsoLine::V(vm), IsoLine::U(um)]
214        })
215    })?;
216    Ok(Built::new(faces.shape, start.history.then(&faces.history)))
217}
218
219/// `shape` with every curve and surface a single Bézier span: converted to
220/// splines, cut at every knot, and each piece's geometry restated as the
221/// span it covers, parameters kept.
222///
223/// What `shape` shares with other shapes is copied before it is
224/// worked on ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
225///
226/// # Errors
227///
228/// As [`ogeom_algo::to_nurbs`] and [`divide_by_continuity`].
229pub fn to_bezier(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
230    ogeom_algo::on_own_nodes(model, shape, &[], |model, shape, _| {
231        to_bezier_own(model, shape, tol)
232    })
233}
234
235/// [`to_bezier`] on a shape every node below which is its own.
236fn to_bezier_own(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
237    let nurbs = ogeom_algo::to_nurbs(model, shape, tol)?;
238    let divided = divide_by_continuity(model, &nurbs.shape, Continuity::CInfinity, tol)?;
239
240    // Each edge's curve restated as its span.
241    let mut reshape = Reshape::new();
242    for edge in explore_unique(model, &divided.shape, ShapeType::Edge)? {
243        let data = edge_data(model, &edge)?;
244        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d().cloned() else {
245            continue;
246        };
247        let Some(Curve::BSpline(spline)) = model.geometry().curve(curve).cloned() else {
248            continue;
249        };
250        if spline.knots().distinct().len() <= 2 {
251            continue;
252        }
253        let span = model
254            .geometry_mut()
255            .add_curve(Curve::BSpline(spline.segment(range, tol)?));
256        let mut fresh = data.clone();
257        for repr in &mut fresh.representations {
258            if let EdgeRepr::Curve3d { curve, .. } = repr {
259                *curve = span;
260            }
261        }
262        let bounds = model.children_of(&forward(&edge))?;
263        let rebuilt = model.add_edge(fresh, &bounds)?;
264        reshape.replace(&forward(&edge), rebuilt);
265    }
266    let spans = if reshape.is_empty() {
267        Built::new(divided.shape.clone(), History::identity())
268    } else {
269        reshape.apply(model, &divided.shape)?
270    };
271
272    // Each face's surface restated as its patch, its edges' trims kept
273    // under the patch's id: the patch keeps the parameters they are in.
274    let mut reshape = Reshape::new();
275    for face in explore_unique(model, &spans.shape, ShapeType::Face)? {
276        let data = face_data(model, &face)?;
277        let Some(SurfaceGeometry::BSpline(spline)) =
278            model.geometry().surface(data.surface).cloned()
279        else {
280            continue;
281        };
282        if spline.u_knots().distinct().len() <= 2 && spline.v_knots().distinct().len() <= 2 {
283            continue;
284        }
285        let rings = rings(model, &forward(&face), data.surface, tol)?;
286        let ((u0, u1), (v0, v1)) = chart_bounds(&rings, tol);
287        let ((du0, du1), (dv0, dv1)) = spline.domain();
288        // A cut's trims stand on its knot line up to their fit; read a
289        // hair off it, the patch would keep a sliver of the next span.
290        let snap = |x: f64, knots: &[(f64, usize)]| {
291            knots
292                .iter()
293                .map(|&(k, _)| k)
294                .find(|k| (k - x).abs() <= tol.parametric())
295                .unwrap_or(x)
296        };
297        let (us, vs) = (spline.u_knots().distinct(), spline.v_knots().distinct());
298        let (u0, u1) = (snap(u0, &us), snap(u1, &us));
299        let (v0, v1) = (snap(v0, &vs), snap(v1, &vs));
300        let patch = spline.segment((u0.max(du0), u1.min(du1)), (v0.max(dv0), v1.min(dv1)), tol)?;
301        let id = model
302            .geometry_mut()
303            .add_surface(SurfaceGeometry::BSpline(patch));
304        // A seam of the whole surface that a cut left bounding this patch
305        // once is no seam of the patch, which does not close across it: it
306        // keeps only the side the face's ring walks, the one inside the
307        // ring's bounds.
308        let walked = explore(model, &forward(&face), Filter::OfType(ShapeType::Edge))?;
309        let outside = |p: Point2| {
310            (u0 - p.x)
311                .max(p.x - u1)
312                .max(0.0)
313                .hypot((v0 - p.y).max(p.y - v1).max(0.0))
314        };
315        for edge in explore_unique(model, &face, ShapeType::Edge)? {
316            let repr = edge_data(model, &edge)?
317                .pcurve_for(data.surface, edge.location())
318                .cloned();
319            let Some(mut repr) = repr else { continue };
320            let once = walked.iter().filter(|e| e.node() == edge.node()).count() == 1;
321            if let (
322                true,
323                EdgeRepr::Seam {
324                    forward: ahead,
325                    reversed: back,
326                    range,
327                    location,
328                    ..
329                },
330            ) = (once, &repr)
331            {
332                let middle = |id| -> OgeomResult<Point2> {
333                    let Some(pcurve) = model.geometry().pcurve(id) else {
334                        ogeom_bail!(Dangling, "pcurve is not in this model");
335                    };
336                    pcurve.point_at(0.5 * (range.0 + range.1), tol)
337                };
338                let curve = if outside(middle(*back)?) < outside(middle(*ahead)?) {
339                    *back
340                } else {
341                    *ahead
342                };
343                repr = EdgeRepr::PCurve {
344                    curve,
345                    range: *range,
346                    surface: data.surface,
347                    location: location.clone(),
348                };
349            }
350            match &mut repr {
351                EdgeRepr::PCurve { surface, .. } | EdgeRepr::Seam { surface, .. } => *surface = id,
352                _ => continue,
353            }
354            let Some(node) = model.node_mut(&edge) else {
355                ogeom_bail!(Dangling, "edge is not in this model");
356            };
357            if let NodeData::Edge(e) = node.data_mut() {
358                let agreed = e.same_parameter();
359                e.add(repr);
360                e.assert_same_parameter(agreed);
361            }
362        }
363        let mut fresh = data.clone();
364        fresh.surface = id;
365        fresh.triangulation = None;
366        let wires = model.children_of(&forward(&face))?;
367        let rebuilt = model.add_face(fresh, &wires)?;
368        reshape.replace(&forward(&face), rebuilt);
369    }
370    let patches = if reshape.is_empty() {
371        Built::new(spans.shape.clone(), History::identity())
372    } else {
373        reshape.apply(model, &spans.shape)?
374    };
375    Ok(Built::new(
376        patches.shape,
377        nurbs
378            .history
379            .then(&divided.history)
380            .then(&spans.history)
381            .then(&patches.history),
382    ))
383}
384
385// --- drivers ---------------------------------------------------------------
386
387pub(crate) type Bounds = ((f64, f64), (f64, f64));
388
389/// Cut faces until `lines` asks for no cut any face takes: each round asks
390/// every face not yet settled for the lines it wants, in order, and makes
391/// the first that crosses it.
392fn divide_faces(
393    model: &mut Model,
394    shape: &Shape,
395    tol: Tolerances,
396    mut lines: impl FnMut(&Model, &Shape, &SurfaceGeometry, Bounds) -> OgeomResult<Vec<IsoLine>>,
397) -> OgeomResult<Built> {
398    let mut current = Built::new(shape.clone(), History::identity());
399    let mut settled: FastSet<TShapeId> = FastSet::default();
400    'rounds: for _ in 0..100_000 {
401        for face in explore_unique(model, &current.shape, ShapeType::Face)? {
402            if settled.contains(&face.node()) {
403                continue;
404            }
405            let data = face_data(model, &face)?;
406            if data.natural_restriction {
407                // Bounded first, by its chart's own sides: then it cuts as
408                // any face does.
409                match bounded_natural(model, &face, tol)? {
410                    Some(bounded) => {
411                        let mut reshape = Reshape::new();
412                        reshape.replace(&forward(&face), bounded);
413                        let next = reshape.apply(model, &current.shape)?;
414                        current = Built::new(next.shape, current.history.then(&next.history));
415                        continue 'rounds;
416                    }
417                    None => {
418                        settled.insert(face.node());
419                        continue;
420                    }
421                }
422            }
423            let Some(surface) = model.geometry().surface(data.surface).cloned() else {
424                ogeom_bail!(Dangling, "surface is not in this model");
425            };
426            let rings = rings(model, &forward(&face), data.surface, tol)?;
427            let bounds = chart_bounds(&rings, tol);
428            for line in lines(&*model, &face, &surface, bounds)? {
429                let (lo, hi) = match line {
430                    IsoLine::U(_) => bounds.0,
431                    IsoLine::V(_) => bounds.1,
432                };
433                let margin = (hi - lo).abs() * 1e-7 + tol.parametric();
434                if line.at() <= lo + margin || line.at() >= hi - margin {
435                    continue;
436                }
437                if let Some(reshape) = cut_face(model, &face, line, tol)? {
438                    let next = reshape.apply(model, &current.shape)?;
439                    current = Built::new(next.shape, current.history.then(&next.history));
440                    continue 'rounds;
441                }
442            }
443            settled.insert(face.node());
444        }
445        return Ok(current);
446    }
447    ogeom_bail!(Construction, "the division did not settle");
448}
449
450/// A face covering its surface's whole chart, rebuilt with that chart's
451/// sides as its boundary: a periodic direction's two sides one seam edge,
452/// a side the surface pinches to a point (a ball's pole) a degenerate edge.
453/// `None` where the chart is unbounded or a side has no closed-form curve.
454pub(crate) fn bounded_natural(
455    model: &mut Model,
456    face: &Shape,
457    tol: Tolerances,
458) -> OgeomResult<Option<Shape>> {
459    let face_fwd = forward(face);
460    let data = face_data(model, &face_fwd)?;
461    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
462        ogeom_bail!(Dangling, "surface is not in this model");
463    };
464    let ((u0, u1), (v0, v1)) = surface.domain();
465    if ![u0, u1, v0, v1]
466        .iter()
467        .all(|x| x.is_finite() && x.abs() < 1e6)
468    {
469        return Ok(None);
470    }
471    let (pu, pv) = (surface.is_periodic_u(), surface.is_periodic_v());
472    let corner = |u: f64, v: f64| surface.point_at(u, v, tol);
473    // Each side as (line, from, to) in the free parameter, walked so the
474    // ring runs counter-clockwise round the chart.
475    let sides = [
476        (IsoLine::V(v0), u0, u1),
477        (IsoLine::U(u1), v0, v1),
478        (IsoLine::V(v1), u1, u0),
479        (IsoLine::U(u0), v1, v0),
480    ];
481    // Vertices by chart corner, one where the corners meet in space.
482    let corners = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
483    let mut vertices: Vec<Shape> = Vec::with_capacity(4);
484    for (i, (u, v)) in corners.iter().enumerate() {
485        let p = corner(*u, *v)?;
486        let found = (0..i).find(|j| {
487            let (a, b) = corners[*j];
488            corner(a, b).is_ok_and(|q| q.distance(p) <= tol.confusion())
489        });
490        vertices.push(match found {
491            Some(j) => vertices[j].clone(),
492            None => model.add_vertex(VertexData::new(p)),
493        });
494    }
495    let chart_line = |line: IsoLine, a: f64, b: f64, t: (f64, f64)| -> OgeomResult<PlanarCurve> {
496        let knots = KnotVector::new(vec![t.0, t.0, t.1, t.1], 1)?;
497        let (p, q) = (line.point(a), line.point(b));
498        let (p, q) = if t.0 <= t.1 { (p, q) } else { (q, p) };
499        Ok(PlanarCurve::BSpline(BSpline2d::new(
500            knots,
501            vec![p, q],
502            tol,
503        )?))
504    };
505    // One edge per side, a periodic pair shared; each side's occurrence.
506    let mut ring: Vec<Shape> = Vec::with_capacity(4);
507    let mut seam_u: Option<Shape> = None;
508    let mut seam_v: Option<Shape> = None;
509    for (index, (line, a, b)) in sides.into_iter().enumerate() {
510        let (from, to) = (vertices[index].clone(), vertices[(index + 1) % 4].clone());
511        let periodic_pair = match line {
512            IsoLine::U(_) => pu,
513            IsoLine::V(_) => pv,
514        };
515        // The second side of a periodic pair is the first's seam, walked
516        // back.
517        if periodic_pair {
518            let held = match line {
519                IsoLine::U(_) => &seam_u,
520                IsoLine::V(_) => &seam_v,
521            };
522            if let Some(seam) = held {
523                ring.push(seam.reversed());
524                continue;
525            }
526        }
527        let (lo, hi) = (a.min(b), a.max(b));
528        let mid = line.point(0.5 * (lo + hi));
529        let pinched = corner(line.point(lo).x, line.point(lo).y)?.distance(corner(mid.x, mid.y)?)
530            <= tol.confusion()
531            && corner(line.point(hi).x, line.point(hi).y)?.distance(corner(mid.x, mid.y)?)
532                <= tol.confusion();
533        let edge = if pinched {
534            let mut edge_data = EdgeData::new();
535            edge_data.degenerate = true;
536            let trim = chart_line(line, lo, hi, (lo, hi))?;
537            let id = model.geometry_mut().add_pcurve(trim);
538            edge_data.add(EdgeRepr::PCurve {
539                curve: id,
540                surface: data.surface,
541                location: Location::identity(),
542                range: (lo, hi),
543            });
544            let pole = model.add_edge(edge_data, &[from.clone(), from.clone()])?;
545            // Its trim runs up the chart; the side may walk it down.
546            if a <= b { pole } else { pole.reversed() }
547        } else {
548            let Some((curve, scale, offset)) =
549                iso_curve(&surface, line, &[0.5 * (lo + hi)], (lo, hi), tol)?
550            else {
551                return Ok(None);
552            };
553            let (t_lo, t_hi) = (scale * lo + offset, scale * hi + offset);
554            let (range, rising) = if t_lo <= t_hi {
555                ((t_lo, t_hi), true)
556            } else {
557                ((t_hi, t_lo), false)
558            };
559            let curve_id = model.geometry_mut().add_curve(curve);
560            let mut edge_data = EdgeData::on_curve(curve_id, Location::identity(), range);
561            // The edge runs with its curve; the side walks from `a` to `b`.
562            let (start_v, end_v) = {
563                let low_end = if a <= b { &from } else { &to };
564                let high_end = if a <= b { &to } else { &from };
565                if rising {
566                    (low_end.clone(), high_end.clone())
567                } else {
568                    (high_end.clone(), low_end.clone())
569                }
570            };
571            let pcurve_at = |line: IsoLine| -> OgeomResult<PlanarCurve> {
572                let (p, q) = if rising {
573                    (line.point(lo), line.point(hi))
574                } else {
575                    (line.point(hi), line.point(lo))
576                };
577                let knots = KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
578                Ok(PlanarCurve::BSpline(BSpline2d::new(
579                    knots,
580                    vec![p, q],
581                    tol,
582                )?))
583            };
584            if periodic_pair {
585                // Its two chart sides: this one, and the one a period on.
586                let other = match line {
587                    IsoLine::U(c) => IsoLine::U(if (c - u1).abs() < (c - u0).abs() {
588                        u0
589                    } else {
590                        u1
591                    }),
592                    IsoLine::V(c) => IsoLine::V(if (c - v1).abs() < (c - v0).abs() {
593                        v0
594                    } else {
595                        v1
596                    }),
597                };
598                let here = model.geometry_mut().add_pcurve(pcurve_at(line)?);
599                let there = model.geometry_mut().add_pcurve(pcurve_at(other)?);
600                edge_data.add(EdgeRepr::Seam {
601                    forward: here,
602                    reversed: there,
603                    surface: data.surface,
604                    location: Location::identity(),
605                    range,
606                });
607            } else {
608                let id = model.geometry_mut().add_pcurve(pcurve_at(line)?);
609                edge_data.add(EdgeRepr::PCurve {
610                    curve: id,
611                    surface: data.surface,
612                    location: Location::identity(),
613                    range,
614                });
615            }
616            edge_data.assert_same_parameter(true);
617            let edge = model.add_edge(edge_data, &[start_v, end_v])?;
618            let walked_with_curve = (a <= b) == rising;
619            let occurrence = if walked_with_curve {
620                edge.clone()
621            } else {
622                edge.reversed()
623            };
624            if periodic_pair {
625                match line {
626                    IsoLine::U(_) => seam_u = Some(occurrence.clone()),
627                    IsoLine::V(_) => seam_v = Some(occurrence.clone()),
628                }
629            }
630            ring.push(occurrence);
631            continue;
632        };
633        ring.push(edge);
634    }
635    let wire = model.add_wire(&ring)?;
636    let mut fresh = data.clone();
637    fresh.natural_restriction = false;
638    fresh.triangulation = None;
639    let bounded = model.add_face(fresh, &[wire])?;
640    Ok(Some(bounded))
641}
642
643/// Cut every edge whose spline is less than `order` times differentiable at
644/// an interior knot, there.
645fn divide_edges(
646    model: &mut Model,
647    shape: &Shape,
648    order: usize,
649    tol: Tolerances,
650) -> OgeomResult<Built> {
651    let mut reshape = Reshape::new();
652    for edge in explore_unique(model, shape, ShapeType::Edge)? {
653        let edge = forward(&edge);
654        let data = edge_data(model, &edge)?;
655        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d().cloned() else {
656            continue;
657        };
658        let Some(geometry) = model.geometry().curve(curve) else {
659            ogeom_bail!(Dangling, "curve is not in this model");
660        };
661        let margin = (range.1 - range.0) * 1e-9 + tol.parametric();
662        let at: Vec<f64> = curve_knots(geometry)
663            .map(|knots| weak_knots(&knots, order))
664            .unwrap_or_default()
665            .into_iter()
666            .filter(|t| *t > range.0 + margin && *t < range.1 - margin)
667            .collect();
668        if at.is_empty() {
669            continue;
670        }
671        let fractions: Vec<f64> = at
672            .iter()
673            .map(|t| (t - range.0) / (range.1 - range.0))
674            .collect();
675        let (pieces, _) = split_edge(model, &edge, &fractions, None, tol)?;
676        reshape.split(&edge, pieces);
677    }
678    if reshape.is_empty() {
679        return Ok(Built::new(shape.clone(), History::identity()));
680    }
681    reshape.apply(model, shape)
682}
683
684/// The shape, its placements baked in where any edge or face is placed:
685/// cuts make new nodes in the chart of the node they cut, and a node placed
686/// twice has two.
687pub(crate) fn placed_baked(
688    model: &mut Model,
689    shape: &Shape,
690    tol: Tolerances,
691) -> OgeomResult<Built> {
692    let mut placed = false;
693    for kind in [ShapeType::Edge, ShapeType::Face] {
694        for s in explore_unique(model, shape, kind)? {
695            placed |= !s.location().is_identity();
696        }
697    }
698    for face in explore_unique(model, shape, ShapeType::Face)? {
699        placed |= !face_data(model, &face)?.location.is_identity();
700    }
701    if placed {
702        ogeom_algo::baked_shape(model, shape, tol)
703    } else {
704        Ok(Built::new(shape.clone(), History::identity()))
705    }
706}
707
708// --- where to cut -----------------------------------------------------------
709
710fn curve_knots(curve: &Curve) -> Option<KnotVector> {
711    match curve {
712        Curve::BSpline(b) => Some(b.knots().clone()),
713        Curve::Trimmed(t) => curve_knots(t.basis()),
714        _ => None,
715    }
716}
717
718/// Interior knots where the spline is less than `order` times
719/// differentiable.
720fn weak_knots(knots: &KnotVector, order: usize) -> Vec<f64> {
721    let (a, b) = knots.domain();
722    let p = knots.degree();
723    knots
724        .distinct()
725        .into_iter()
726        .filter(|(value, m)| *value > a && *value < b && p.saturating_sub(*m) < order)
727        .map(|(value, _)| value)
728        .collect()
729}
730
731/// The knot lines of a surface where it is less than `order` times
732/// differentiable.
733fn knot_lines(surface: &SurfaceGeometry, order: usize) -> Vec<IsoLine> {
734    match surface {
735        SurfaceGeometry::BSpline(b) => weak_knots(b.u_knots(), order)
736            .into_iter()
737            .map(IsoLine::U)
738            .chain(weak_knots(b.v_knots(), order).into_iter().map(IsoLine::V))
739            .collect(),
740        SurfaceGeometry::Extrusion(e) => curve_knots(e.curve())
741            .map(|k| weak_knots(&k, order).into_iter().map(IsoLine::U).collect())
742            .unwrap_or_default(),
743        SurfaceGeometry::Revolution(r) => curve_knots(r.curve())
744            .map(|k| weak_knots(&k, order).into_iter().map(IsoLine::V).collect())
745            .unwrap_or_default(),
746        SurfaceGeometry::Trimmed(t) => knot_lines(t.basis(), order),
747        // An offset is one order rougher than its basis at a knot.
748        SurfaceGeometry::Offset(o) => knot_lines(o.basis(), order.saturating_add(1)),
749        _ => Vec::new(),
750    }
751}
752
753/// Whether each parameter of a surface is an angle.
754const fn angular(surface: &SurfaceGeometry) -> (bool, bool) {
755    match surface {
756        SurfaceGeometry::Cylinder(_)
757        | SurfaceGeometry::Cone(_)
758        | SurfaceGeometry::Revolution(_) => (true, false),
759        SurfaceGeometry::Sphere(_) | SurfaceGeometry::Torus(_) => (true, true),
760        _ => (false, false),
761    }
762}
763
764// --- one cut ------------------------------------------------------------------
765
766/// One traversal of an edge around a face, in the chart.
767#[derive(Debug, Clone)]
768pub(crate) struct Occurrence {
769    pub(crate) edge: Shape,
770    pcurve: PlanarCurve,
771    range: (f64, f64),
772}
773
774impl Occurrence {
775    pub(crate) fn reversed(&self) -> bool {
776        self.edge.orientation() == Orientation::Reversed
777    }
778
779    /// The chart point a fraction `s` of the way along the traversal.
780    pub(crate) fn at(&self, s: f64, tol: Tolerances) -> OgeomResult<Point2> {
781        let s = if self.reversed() { 1.0 - s } else { s };
782        self.pcurve
783            .point_at(self.range.0 + (self.range.1 - self.range.0) * s, tol)
784    }
785
786    pub(crate) fn polyline(&self, samples: usize, tol: Tolerances) -> OgeomResult<Vec<Point2>> {
787        (0..=samples)
788            .map(|i| {
789                #[allow(clippy::cast_precision_loss)]
790                let s = i as f64 / samples as f64;
791                self.at(s, tol)
792            })
793            .collect()
794    }
795}
796
797/// An edge's trim on a face: one pcurve, or a seam's two, over a range.
798type Trims = (PlanarCurve, Option<PlanarCurve>, (f64, f64));
799
800/// A ring's edges, its chart outline, and the holes it holds.
801type Outer = (Vec<Shape>, Vec<Point2>, Vec<Vec<Shape>>);
802
803/// A face's rings as traversals, each seam traversal on the side of the
804/// chart its ring continues on.
805pub(crate) fn rings(
806    model: &Model,
807    face: &Shape,
808    surface: SurfaceId,
809    tol: Tolerances,
810) -> OgeomResult<Vec<Vec<Occurrence>>> {
811    let mut out = Vec::new();
812    for wire in model.ordered_children_of(face)? {
813        let edges = model.ordered_children_of(&wire)?;
814        let mut choices: Vec<Option<Trims>> = Vec::new();
815        for edge in &edges {
816            let data = edge_data(model, edge)?;
817            let pcurve =
818                |id| -> OgeomResult<PlanarCurve> {
819                    model.geometry().pcurve(id).cloned().ok_or_else(|| {
820                        ogeom_core::ogeom_err!(Dangling, "pcurve is not in this model")
821                    })
822                };
823            choices.push(match data.pcurve_for(surface, edge.location()) {
824                Some(EdgeRepr::PCurve { curve, range, .. }) => {
825                    Some((pcurve(*curve)?, None, *range))
826                }
827                Some(EdgeRepr::Seam {
828                    forward,
829                    reversed,
830                    range,
831                    ..
832                }) => Some((pcurve(*forward)?, Some(pcurve(*reversed)?), *range)),
833                _ => None,
834            });
835        }
836        // Walked from an edge off the seam where there is one. A ring of
837        // seams alone (a torus's) has no such edge, and where its seams are
838        // cut into pieces every corner is one vertex, so the side its first
839        // occurrence takes is tried both ways: the right one closes the
840        // ring in the chart.
841        let first_plain = choices
842            .iter()
843            .position(|c| c.as_ref().is_some_and(|c| c.1.is_none()));
844        let start = first_plain.unwrap_or(0);
845        let walk = |flip: bool| -> OgeomResult<(Vec<Occurrence>, f64)> {
846            let n = edges.len();
847            let mut ring: Vec<Option<Occurrence>> = vec![None; n];
848            let mut last: Option<Point2> = None;
849            let mut gap = 0.0;
850            for k in 0..n {
851                let i = (start + k) % n;
852                let Some((a, b, range)) = choices[i].clone() else {
853                    ogeom_bail!(Construction, "an edge has no trim on this face");
854                };
855                let mut occurrence = Occurrence {
856                    edge: edges[i].clone(),
857                    pcurve: a,
858                    range,
859                };
860                if let Some(b) = b {
861                    let other = Occurrence {
862                        pcurve: b,
863                        ..occurrence.clone()
864                    };
865                    let take_other = match last {
866                        Some(last) => {
867                            other.at(0.0, tol)?.distance(last)
868                                < occurrence.at(0.0, tol)?.distance(last)
869                        }
870                        None => occurrence.reversed() != flip,
871                    };
872                    if take_other {
873                        occurrence = other;
874                    }
875                }
876                if let Some(last) = last {
877                    gap += occurrence.at(0.0, tol)?.distance(last);
878                }
879                last = Some(occurrence.at(1.0, tol)?);
880                ring[i] = Some(occurrence);
881            }
882            let ring: Vec<Occurrence> = ring.into_iter().flatten().collect();
883            if let (Some(first), Some(last)) = (ring.first(), last) {
884                gap += first.at(0.0, tol)?.distance(last);
885            }
886            Ok((ring, gap))
887        };
888        let (mut ring, gap) = walk(false)?;
889        if first_plain.is_none() && gap > tol.parametric() {
890            let (flipped, flipped_gap) = walk(true)?;
891            if flipped_gap < gap {
892                ring = flipped;
893            }
894        }
895        out.push(ring);
896    }
897    Ok(out)
898}
899
900pub(crate) fn chart_bounds(rings: &[Vec<Occurrence>], tol: Tolerances) -> Bounds {
901    let mut b = (
902        (f64::INFINITY, f64::NEG_INFINITY),
903        (f64::INFINITY, f64::NEG_INFINITY),
904    );
905    for o in rings.iter().flatten() {
906        for i in 0..=32 {
907            if let Ok(p) = o.at(f64::from(i) / 32.0, tol) {
908                b.0 = (b.0.0.min(p.x), b.0.1.max(p.x));
909                b.1 = (b.1.0.min(p.y), b.1.1.max(p.y));
910            }
911        }
912    }
913    b
914}
915
916/// Where a line meets a face's boundary.
917#[derive(Debug, Clone, Copy)]
918enum Meeting {
919    /// At an existing vertex.
920    Vertex(TShapeId),
921    /// Inside an edge, a fraction of the way along it (forward).
922    Inside(TShapeId, u64),
923}
924
925/// The substitutions that cut `face` along `line`, or `None` where the
926/// line does not cross it.
927#[allow(clippy::too_many_lines)]
928fn cut_face(
929    model: &mut Model,
930    face: &Shape,
931    line: IsoLine,
932    tol: Tolerances,
933) -> OgeomResult<Option<Reshape>> {
934    let face_fwd = forward(face);
935    let data = face_data(model, &face_fwd)?;
936    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
937        ogeom_bail!(Dangling, "surface is not in this model");
938    };
939    let rings = rings(model, &face_fwd, data.surface, tol)?;
940    let bounds = chart_bounds(&rings, tol);
941    let span = (bounds.0.1 - bounds.0.0).max(bounds.1.1 - bounds.1.0);
942    let eps = span * 1e-9 + tol.parametric();
943    let c = line.at();
944
945    // Where the line crosses each edge, as fractions of the edge's forward
946    // range; and every point where it meets the boundary, along the line.
947    let mut cuts: FastMap<TShapeId, Vec<f64>> = FastMap::default();
948    let mut meetings: Vec<(f64, Meeting)> = Vec::new();
949    for occurrence in rings.iter().flatten() {
950        let f = |s: f64| -> OgeomResult<f64> { Ok(line.fixed(occurrence.at(s, tol)?) - c) };
951        const N: usize = 64;
952        #[allow(clippy::cast_precision_loss)]
953        let values: Vec<f64> = (0..=N)
954            .map(|i| f(i as f64 / N as f64))
955            .collect::<OgeomResult<_>>()?;
956        if values.iter().all(|v| v.abs() <= eps) {
957            ogeom_bail!(Construction, "an edge of the face runs along {line:?}");
958        }
959        let end = occurrence.at(1.0, tol)?;
960        if (line.fixed(end) - c).abs() <= eps {
961            let Some((_, v)) = edge_vertices(model, &occurrence.edge)? else {
962                continue;
963            };
964            meetings.push((line.free(end), Meeting::Vertex(v.node())));
965        }
966        for i in 0..N {
967            let (a, b) = (values[i], values[i + 1]);
968            if a.abs() <= eps || b.abs() <= eps || a.signum() == b.signum() {
969                // Through a sample exactly: a crossing there only where the
970                // sign changes across it.
971                if i + 1 < N
972                    && b.abs() <= eps
973                    && a.abs() > eps
974                    && values[i + 2].abs() > eps
975                    && a.signum() != values[i + 2].signum()
976                {
977                    #[allow(clippy::cast_precision_loss)]
978                    let s = (i + 1) as f64 / N as f64;
979                    record(
980                        model,
981                        &surface,
982                        occurrence,
983                        s,
984                        line,
985                        &mut cuts,
986                        &mut meetings,
987                        tol,
988                    )?;
989                }
990                continue;
991            }
992            #[allow(clippy::cast_precision_loss)]
993            let (mut lo, mut hi) = (i as f64 / N as f64, (i + 1) as f64 / N as f64);
994            let mut f_lo = a;
995            for _ in 0..80 {
996                let mid = 0.5 * (lo + hi);
997                let f_mid = f(mid)?;
998                if f_mid.signum() == f_lo.signum() {
999                    (lo, f_lo) = (mid, f_mid);
1000                } else {
1001                    hi = mid;
1002                }
1003            }
1004            record(
1005                model,
1006                &surface,
1007                occurrence,
1008                0.5 * (lo + hi),
1009                line,
1010                &mut cuts,
1011                &mut meetings,
1012                tol,
1013            )?;
1014        }
1015    }
1016    meetings.sort_by(|a, b| a.0.total_cmp(&b.0));
1017    meetings.dedup_by(|b, a| {
1018        (a.0 - b.0).abs() <= eps
1019            && match (a.1, b.1) {
1020                (Meeting::Vertex(x), Meeting::Vertex(y)) => x == y,
1021                (Meeting::Inside(x, s), Meeting::Inside(y, t)) => x == y && s == t,
1022                _ => false,
1023            }
1024    });
1025
1026    // The stretches of the line inside the face.
1027    let outlines: Vec<Vec<Point2>> = rings
1028        .iter()
1029        .map(|ring| -> OgeomResult<Vec<Point2>> {
1030            let mut out = Vec::new();
1031            for o in ring {
1032                out.extend(o.polyline(32, tol)?);
1033            }
1034            Ok(out)
1035        })
1036        .collect::<OgeomResult<_>>()?;
1037    let mut stretches: Vec<(usize, usize)> = Vec::new();
1038    for i in 0..meetings.len().saturating_sub(1) {
1039        let (w0, w1) = (meetings[i].0, meetings[i + 1].0);
1040        if w1 - w0 <= eps * 10.0 {
1041            continue;
1042        }
1043        if inside(&outlines, line.point(0.5 * (w0 + w1))) {
1044            stretches.push((i, i + 1));
1045        }
1046    }
1047    if stretches.is_empty() {
1048        return Ok(None);
1049    }
1050    let outer_sign = outlines
1051        .iter()
1052        .map(|o| signed_area(o))
1053        .max_by(|a, b| a.abs().total_cmp(&b.abs()))
1054        .unwrap_or(1.0)
1055        .signum();
1056
1057    // Cut the edges, and every meeting inside one becomes its new vertex.
1058    let mut reshape = Reshape::new();
1059    let mut pieces_of: FastMap<TShapeId, Vec<(Shape, f64, f64)>> = FastMap::default();
1060    let mut vertex_at: FastMap<(TShapeId, u64), Shape> = FastMap::default();
1061    let mut vertex_of_node: FastMap<TShapeId, Shape> = FastMap::default();
1062    for v in explore_unique(model, &face_fwd, ShapeType::Vertex)? {
1063        vertex_of_node.insert(v.node(), v);
1064    }
1065    for occurrence in rings.iter().flatten() {
1066        let edge = forward(&occurrence.edge);
1067        if pieces_of.contains_key(&edge.node()) {
1068            continue;
1069        }
1070        let Some(fractions) = cuts.get(&edge.node()) else {
1071            continue;
1072        };
1073        let mut fractions = fractions.clone();
1074        fractions.sort_by(f64::total_cmp);
1075        fractions.dedup_by(|b, a| (*a - *b).abs() <= 1e-12);
1076        let (pieces, vertices) = split_edge(model, &edge, &fractions, Some(data.surface), tol)?;
1077        for (s, v) in fractions.iter().zip(&vertices) {
1078            vertex_at.insert((edge.node(), s.to_bits()), v.clone());
1079        }
1080        let mut bounds = vec![0.0];
1081        bounds.extend(&fractions);
1082        bounds.push(1.0);
1083        pieces_of.insert(
1084            edge.node(),
1085            pieces
1086                .iter()
1087                .zip(bounds.windows(2))
1088                .map(|(p, w)| (p.clone(), w[0], w[1]))
1089                .collect(),
1090        );
1091        reshape.split(&edge, pieces);
1092    }
1093    let meeting_vertex = |m: Meeting| -> OgeomResult<Shape> {
1094        match m {
1095            Meeting::Vertex(node) => vertex_of_node
1096                .get(&node)
1097                .cloned()
1098                .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a meeting vertex is lost")),
1099            Meeting::Inside(edge, s) => vertex_at
1100                .get(&(edge, s))
1101                .cloned()
1102                .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a cut vertex is lost")),
1103        }
1104    };
1105
1106    // The boundary's pieces, each on its side of the line.
1107    let mut sides: [Vec<Item>; 2] = [Vec::new(), Vec::new()];
1108    for occurrence in rings.iter().flatten() {
1109        let edge = forward(&occurrence.edge);
1110        let parts: Vec<(Shape, f64, f64)> = match pieces_of.get(&edge.node()) {
1111            Some(parts) => parts.clone(),
1112            None => vec![(edge.clone(), 0.0, 1.0)],
1113        };
1114        let mut ordered: Vec<(Shape, f64, f64)> = if occurrence.reversed() {
1115            parts
1116                .into_iter()
1117                .rev()
1118                .map(|(p, a, b)| (p.reversed(), 1.0 - b, 1.0 - a))
1119                .collect()
1120        } else {
1121            parts
1122        };
1123        for (piece, a, b) in ordered.drain(..) {
1124            let sub = |s: f64| occurrence.at(a + (b - a) * s, tol);
1125            let polyline: Vec<Point2> = (0..=16)
1126                .map(|i| sub(f64::from(i) / 16.0))
1127                .collect::<OgeomResult<_>>()?;
1128            let side = usize::from(line.fixed(polyline[8]) > c);
1129            let Some((from, to)) = edge_vertices(model, &piece)? else {
1130                ogeom_bail!(Construction, "a boundary piece has no vertices");
1131            };
1132            sides[side].push(Item {
1133                edge: piece,
1134                from: from.node(),
1135                to: to.node(),
1136                polyline,
1137            });
1138        }
1139    }
1140
1141    // The new edges along the line, each walked one way by each side.
1142    let probes: Vec<f64> = stretches
1143        .iter()
1144        .map(|&(i, j)| 0.5 * (meetings[i].0 + meetings[j].0))
1145        .collect();
1146    let span = (
1147        meetings.first().map_or(0.0, |m| m.0),
1148        meetings.last().map_or(0.0, |m| m.0),
1149    );
1150    let Some((curve, scale, offset)) = iso_curve(&surface, line, &probes, span, tol)? else {
1151        ogeom_bail!(
1152            Construction,
1153            "the surface has no closed-form iso-curve to cut along"
1154        );
1155    };
1156    for (i, j) in stretches {
1157        let (w0, w1) = (meetings[i].0, meetings[j].0);
1158        let (a, b) = (
1159            meeting_vertex(meetings[i].1)?,
1160            meeting_vertex(meetings[j].1)?,
1161        );
1162        let (t0, t1) = (scale * w0 + offset, scale * w1 + offset);
1163        let rising = t1 > t0;
1164        let (range, (start, end), (p0, p1)) = if rising {
1165            ((t0, t1), (&a, &b), (line.point(w0), line.point(w1)))
1166        } else {
1167            ((t1, t0), (&b, &a), (line.point(w1), line.point(w0)))
1168        };
1169        let curve_id = model.geometry_mut().add_curve(curve.clone());
1170        let mut edge_data = EdgeData::on_curve(curve_id, Location::identity(), range);
1171        let trim = BSpline2d::new(
1172            KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?,
1173            vec![p0, p1],
1174            tol,
1175        )?;
1176        let trim_id = model.geometry_mut().add_pcurve(PlanarCurve::BSpline(trim));
1177        edge_data.add(EdgeRepr::PCurve {
1178            curve: trim_id,
1179            surface: data.surface,
1180            location: Location::identity(),
1181            range,
1182        });
1183        edge_data.assert_same_parameter(true);
1184        // The vertices were placed by the edges they cut; the new edge's
1185        // ends reach them within the gap.
1186        let reach = [(start, range.0), (end, range.1)]
1187            .iter()
1188            .map(|(v, t)| -> OgeomResult<f64> {
1189                Ok(vertex_point(model, v)?.distance(curve.point_at(*t, tol)?))
1190            })
1191            .collect::<OgeomResult<Vec<f64>>>()?
1192            .into_iter()
1193            .fold(0.0_f64, f64::max);
1194        let edge = model.add_edge(edge_data, &[start.clone(), end.clone()])?;
1195        model.widen(&edge, ogeom_core::Tolerance::new(reach + tol.confusion())?)?;
1196        let polyline: Vec<Point2> = (0..=16)
1197            .map(|k| {
1198                let w = w0 + (w1 - w0) * f64::from(k) / 16.0;
1199                line.point(w)
1200            })
1201            .collect();
1202        // Walked up the line (increasing free parameter), the material on
1203        // the left is the low side of a `u` line and the high side of a `v`
1204        // line, for a boundary wound counter-clockwise.
1205        let left = match line {
1206            IsoLine::U(_) => 0,
1207            IsoLine::V(_) => 1,
1208        };
1209        let up_side = if outer_sign >= 0.0 { left } else { 1 - left };
1210        let up = if rising {
1211            edge.clone()
1212        } else {
1213            edge.reversed()
1214        };
1215        let down = up.reversed();
1216        sides[up_side].push(Item {
1217            edge: up,
1218            from: a.node(),
1219            to: b.node(),
1220            polyline: polyline.clone(),
1221        });
1222        sides[1 - up_side].push(Item {
1223            edge: down,
1224            from: b.node(),
1225            to: a.node(),
1226            polyline: polyline.into_iter().rev().collect(),
1227        });
1228    }
1229
1230    // Each side's pieces chained into rings, and the rings into faces.
1231    let mut faces: Vec<Shape> = Vec::new();
1232    for items in sides {
1233        let loops = chain(items, eps.max(tol.parametric() * 1e3))?;
1234        let mut outers: Vec<Outer> = Vec::new();
1235        let mut holes: Vec<(Vec<Shape>, Vec<Point2>)> = Vec::new();
1236        for (edges, outline) in loops {
1237            if signed_area(&outline) * outer_sign > 0.0 {
1238                outers.push((edges, outline, Vec::new()));
1239            } else {
1240                holes.push((edges, outline));
1241            }
1242        }
1243        for (edges, outline) in holes {
1244            let probe = outline[outline.len() / 2];
1245            let Some(host) = outers
1246                .iter_mut()
1247                .filter(|o| inside(std::slice::from_ref(&o.1), probe))
1248                .min_by(|a, b| signed_area(&a.1).abs().total_cmp(&signed_area(&b.1).abs()))
1249            else {
1250                ogeom_bail!(Construction, "a hole of the cut face lies in no piece");
1251            };
1252            host.2.push(edges);
1253        }
1254        for (edges, _, hole_edges) in outers {
1255            let mut wires = vec![model.add_wire(&edges)?];
1256            for h in hole_edges {
1257                wires.push(model.add_wire(&h)?);
1258            }
1259            let mut fresh = data.clone();
1260            fresh.triangulation = None;
1261            fresh.natural_restriction = false;
1262            faces.push(model.add_face(fresh, &wires)?);
1263        }
1264    }
1265    if faces.len() < 2 {
1266        return Ok(None);
1267    }
1268    reshape.split(&face_fwd, faces);
1269    Ok(Some(reshape))
1270}
1271
1272/// Record a crossing a fraction `s` along `occurrence`. A crossing that
1273/// lands within an end vertex's radius of it, lifted through the face's
1274/// surface, meets that vertex: the line passes through it as the face
1275/// states it, and cutting the edge there would leave a sliver shorter
1276/// than the vertex is wide. A degenerate edge, every point of which is its
1277/// vertex, is cut where the line crosses its row.
1278#[allow(clippy::too_many_arguments)]
1279fn record(
1280    model: &Model,
1281    surface: &SurfaceGeometry,
1282    occurrence: &Occurrence,
1283    s: f64,
1284    line: IsoLine,
1285    cuts: &mut FastMap<TShapeId, Vec<f64>>,
1286    meetings: &mut Vec<(f64, Meeting)>,
1287    tol: Tolerances,
1288) -> OgeomResult<()> {
1289    let at = occurrence.at(s, tol)?;
1290    let degenerate = model
1291        .node(&occurrence.edge)
1292        .and_then(|n| n.data().as_edge())
1293        .is_none_or(|d| d.degenerate);
1294    if !degenerate && let Some((first, last)) = edge_vertices(model, &occurrence.edge)? {
1295        let lifted = surface.point_at(at.x, at.y, tol)?;
1296        for (end, vertex) in [(0.0, first), (1.0, last)] {
1297            let held = model
1298                .node(&vertex)
1299                .and_then(|n| n.data().as_vertex())
1300                .map_or(0.0, |d| d.tolerance.get());
1301            if vertex_point(model, &vertex)?.distance(lifted) <= held + tol.confusion() {
1302                let corner = occurrence.at(end, tol)?;
1303                meetings.push((line.free(corner), Meeting::Vertex(vertex.node())));
1304                return Ok(());
1305            }
1306        }
1307    }
1308    // As a fraction of the forward edge, rounded so two traversals of one
1309    // seam name the same point.
1310    let forward_s = if occurrence.reversed() { 1.0 - s } else { s };
1311    let forward_s = (forward_s * 1e12).round() / 1e12;
1312    let node = occurrence.edge.node();
1313    cuts.entry(node).or_default().push(forward_s);
1314    meetings.push((line.free(at), Meeting::Inside(node, forward_s.to_bits())));
1315    Ok(())
1316}
1317
1318/// One directed piece of a ring being assembled.
1319#[derive(Debug, Clone)]
1320struct Item {
1321    edge: Shape,
1322    from: TShapeId,
1323    to: TShapeId,
1324    polyline: Vec<Point2>,
1325}
1326
1327/// Chain directed pieces end to start into closed rings: at a vertex, the
1328/// piece whose chart start is nearest where the ring stands.
1329fn chain(mut items: Vec<Item>, snap: f64) -> OgeomResult<Vec<(Vec<Shape>, Vec<Point2>)>> {
1330    let mut loops = Vec::new();
1331    while let Some(first) = items.pop() {
1332        let start_node = first.from;
1333        let start_at = first.polyline[0];
1334        let mut here = *first.polyline.last().unwrap_or(&start_at);
1335        let mut at_node = first.to;
1336        let mut edges = vec![first.edge];
1337        let mut outline = first.polyline;
1338        loop {
1339            if at_node == start_node && here.distance(start_at) <= snap {
1340                break;
1341            }
1342            let next = items
1343                .iter()
1344                .enumerate()
1345                .filter(|(_, it)| it.from == at_node)
1346                .min_by(|a, b| {
1347                    a.1.polyline[0]
1348                        .distance(here)
1349                        .total_cmp(&b.1.polyline[0].distance(here))
1350                })
1351                .map(|(i, _)| i);
1352            let Some(i) = next else {
1353                ogeom_bail!(Construction, "a piece of the cut face does not close");
1354            };
1355            let it = items.swap_remove(i);
1356            here = *it.polyline.last().unwrap_or(&here);
1357            at_node = it.to;
1358            edges.push(it.edge);
1359            outline.extend(it.polyline);
1360        }
1361        loops.push((edges, outline));
1362    }
1363    Ok(loops)
1364}
1365
1366/// Split `edge` (forward) at fractions of its range, in order: the pieces
1367/// in order, and the new vertex at each fraction.
1368///
1369/// The fractions are measured along one description: the edge's pcurve on
1370/// `along` where one is named (a cut found in a face's chart), its curve
1371/// otherwise. Every other description is cut where it passes the same
1372/// point, which is the same fraction of its own range only where the edge
1373/// states its descriptions run at one parameter over one range: a pcurve
1374/// paced its own way (an arc's rational spline against its patch's chart)
1375/// reaches the same fraction somewhere else along the edge.
1376pub(crate) fn split_edge(
1377    model: &mut Model,
1378    edge: &Shape,
1379    fractions: &[f64],
1380    along: Option<SurfaceId>,
1381    tol: Tolerances,
1382) -> OgeomResult<(Vec<Shape>, Vec<Shape>)> {
1383    let data = edge_data(model, edge)?;
1384    let Some((start, end)) = edge_vertices(model, edge)? else {
1385        ogeom_bail!(Construction, "an edge with no vertices cannot be cut");
1386    };
1387    let measured =
1388        along.and_then(
1389            |surface| match data.pcurve_for(surface, &Location::identity()) {
1390                Some(
1391                    EdgeRepr::PCurve { curve, range, .. }
1392                    | EdgeRepr::Seam {
1393                        forward: curve,
1394                        range,
1395                        ..
1396                    },
1397                ) => Some((surface, *curve, *range)),
1398                _ => None,
1399            },
1400        );
1401    let curve = match data.curve3d() {
1402        Some(EdgeRepr::Curve3d { curve, range, .. }) if !data.degenerate => {
1403            let Some(geometry) = model.geometry().curve(*curve) else {
1404                ogeom_bail!(Dangling, "curve is not in this model");
1405            };
1406            Some((geometry.clone(), *range))
1407        }
1408        _ => None,
1409    };
1410    // Whether a description runs at the measured one's parameters over
1411    // its range, as the edge states, so the same fractions cut it.
1412    let same = |range: (f64, f64)| {
1413        let reference = match (measured, &curve) {
1414            (Some((_, _, r)), _) => Some(r),
1415            (None, Some((_, r))) => Some(*r),
1416            (None, None) => None,
1417        };
1418        data.same_parameter() && reference == Some(range)
1419    };
1420    let points: Vec<Option<Point>> = fractions
1421        .iter()
1422        .map(|&s| -> OgeomResult<Option<Point>> {
1423            Ok(match (measured, &curve) {
1424                (Some((surface, pcurve, range)), _) => {
1425                    let (Some(surface), Some(pcurve)) = (
1426                        model.geometry().surface(surface),
1427                        model.geometry().pcurve(pcurve),
1428                    ) else {
1429                        ogeom_bail!(Dangling, "an edge names geometry not in this model");
1430                    };
1431                    let uv = pcurve.point_at(range.0 + (range.1 - range.0) * s, tol)?;
1432                    Some(surface.point_at(uv.x, uv.y, tol)?)
1433                }
1434                (None, Some((c, range))) => {
1435                    Some(c.point_at(range.0 + (range.1 - range.0) * s, tol)?)
1436                }
1437                (None, None) => None,
1438            })
1439        })
1440        .collect::<OgeomResult<_>>()?;
1441    // Each description's own fractions, in the order the edge holds them.
1442    let mut own: Vec<Vec<f64>> = Vec::with_capacity(data.representations.len());
1443    for repr in &data.representations {
1444        let cut = match (repr, &curve) {
1445            (EdgeRepr::Curve3d { range, .. }, Some((c, _)))
1446                if measured.is_some() && !same(*range) =>
1447            {
1448                passing(fractions, &points, |s| {
1449                    c.point_at(range.0 + (range.1 - range.0) * s, tol).ok()
1450                })?
1451            }
1452            (
1453                EdgeRepr::PCurve {
1454                    curve: id,
1455                    range,
1456                    surface,
1457                    location,
1458                }
1459                | EdgeRepr::Seam {
1460                    forward: id,
1461                    range,
1462                    surface,
1463                    location,
1464                    ..
1465                },
1466                _,
1467            ) => {
1468                let measuring = measured.is_some_and(|(on, _, _)| on == *surface);
1469                if measuring || same(*range) || !location.is_identity() {
1470                    fractions.to_vec()
1471                } else {
1472                    let (Some(surface), Some(pcurve)) = (
1473                        model.geometry().surface(*surface),
1474                        model.geometry().pcurve(*id),
1475                    ) else {
1476                        ogeom_bail!(Dangling, "an edge names geometry not in this model");
1477                    };
1478                    passing(fractions, &points, |s| {
1479                        let uv = pcurve
1480                            .point_at(range.0 + (range.1 - range.0) * s, tol)
1481                            .ok()?;
1482                        surface.point_at(uv.x, uv.y, tol).ok()
1483                    })?
1484                }
1485            }
1486            _ => fractions.to_vec(),
1487        };
1488        own.push(cut);
1489    }
1490    // The new vertices stand on the curve, where it is cut.
1491    let curve_cut = data
1492        .representations
1493        .iter()
1494        .position(|r| matches!(r, EdgeRepr::Curve3d { .. }))
1495        .map_or_else(|| fractions.to_vec(), |k| own[k].clone());
1496    let mut vertices = Vec::with_capacity(fractions.len());
1497    for &s in &curve_cut {
1498        let vertex = match &curve {
1499            Some((c, range)) => {
1500                let p = c.point_at(range.0 + (range.1 - range.0) * s, tol)?;
1501                model.add_vertex(VertexData::with_tolerance(p, data.tolerance.get())?)
1502            }
1503            // A degenerate edge is one point, however it is cut.
1504            _ => start.clone(),
1505        };
1506        vertices.push(vertex);
1507    }
1508    let mut ends = vec![start];
1509    ends.extend(vertices.iter().cloned());
1510    ends.push(end);
1511    let mut pieces = Vec::with_capacity(fractions.len() + 1);
1512    for k in 0..=fractions.len() {
1513        let mut piece = data.clone();
1514        for (repr, cut) in piece.representations.iter_mut().zip(&own) {
1515            let (a, b) = (
1516                if k == 0 { 0.0 } else { cut[k - 1] },
1517                cut.get(k).copied().unwrap_or(1.0),
1518            );
1519            match repr {
1520                EdgeRepr::Curve3d { range, .. }
1521                | EdgeRepr::PCurve { range, .. }
1522                | EdgeRepr::Seam { range, .. } => {
1523                    *range = (
1524                        range.0 + (range.1 - range.0) * a,
1525                        range.0 + (range.1 - range.0) * b,
1526                    );
1527                }
1528                _ => {}
1529            }
1530        }
1531        piece.representations.retain(|r| {
1532            matches!(
1533                r,
1534                EdgeRepr::Curve3d { .. } | EdgeRepr::PCurve { .. } | EdgeRepr::Seam { .. }
1535            )
1536        });
1537        pieces.push(model.add_edge(piece, &[ends[k].clone(), ends[k + 1].clone()])?);
1538    }
1539    Ok((pieces, vertices))
1540}
1541
1542/// The fractions at which a description, read through `at`, passes each
1543/// of `points`: for each, the nearest of evenly spaced samples, refined
1544/// between its neighbours. The fraction given stands where there is no
1545/// point (a degenerate edge) or the description cannot be read.
1546fn passing(
1547    fractions: &[f64],
1548    points: &[Option<Point>],
1549    at: impl Fn(f64) -> Option<Point>,
1550) -> OgeomResult<Vec<f64>> {
1551    const SAMPLES: u32 = 64;
1552    let mut out = Vec::with_capacity(fractions.len());
1553    for (&s, p) in fractions.iter().zip(points) {
1554        let Some(p) = p else {
1555            out.push(s);
1556            continue;
1557        };
1558        let off = |t: f64| at(t).map_or(f64::INFINITY, |q| q.distance(*p));
1559        let nearest = (0..=SAMPLES)
1560            .map(|i| (i, off(f64::from(i) / f64::from(SAMPLES))))
1561            .min_by(|a, b| a.1.total_cmp(&b.1));
1562        let Some((i, _)) = nearest.filter(|(_, d)| d.is_finite()) else {
1563            out.push(s);
1564            continue;
1565        };
1566        let (lo, hi) = (
1567            f64::from(i.saturating_sub(1)) / f64::from(SAMPLES),
1568            f64::from((i + 1).min(SAMPLES)) / f64::from(SAMPLES),
1569        );
1570        out.push(ogeom_math::solve::minimize(off, lo, hi, ogeom_math::Criteria::default())?.value);
1571    }
1572    Ok(out)
1573}
1574
1575/// The surface's iso-curve along `line`, and the map from the line's free
1576/// parameter `w` to the curve's own: `t = scale * w + offset`.
1577fn iso_curve(
1578    surface: &SurfaceGeometry,
1579    line: IsoLine,
1580    probes: &[f64],
1581    span: (f64, f64),
1582    tol: Tolerances,
1583) -> OgeomResult<Option<(Curve, f64, f64)>> {
1584    let c = line.at();
1585    let circle = |centre: Point, z: Direction, x: Point, radius: f64| -> OgeomResult<Curve> {
1586        let x = Direction::new(x - centre, tol)?;
1587        let frame = Frame::new(centre, z, x, tol)?;
1588        Ok(CircleCurve::new(Circle::new(frame, radius, tol)?).into())
1589    };
1590    let straight = |at: Point, along: ogeom_math::Vector| -> OgeomResult<(Curve, f64)> {
1591        let length = along.magnitude();
1592        let direction = Direction::new(along, tol)?;
1593        Ok((
1594            LineCurve::new(Axis {
1595                location: at,
1596                direction,
1597            })
1598            .into(),
1599            length,
1600        ))
1601    };
1602    let found: (Curve, f64, f64) = match (surface, line) {
1603        (SurfaceGeometry::BSpline(b), IsoLine::U(_)) => {
1604            (Curve::BSpline(b.iso_u_curve(c, tol)?), 1.0, 0.0)
1605        }
1606        (SurfaceGeometry::BSpline(b), IsoLine::V(_)) => {
1607            (Curve::BSpline(b.iso_v_curve(c, tol)?), 1.0, 0.0)
1608        }
1609        (
1610            SurfaceGeometry::Plane(_) | SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_),
1611            IsoLine::U(_),
1612        )
1613        | (SurfaceGeometry::Plane(_) | SurfaceGeometry::Extrusion(_), _) => {
1614            // Straight in the free parameter: through the point at the
1615            // span's start, along the rate the surface moves with w, both
1616            // read inside the span (a short drum's window ends before
1617            // w = 1, and the surface refuses to be read past it).
1618            let (lo, hi) = if span.0.is_finite() && span.1.is_finite() && span.1 > span.0 {
1619                span
1620            } else {
1621                (0.0, 1.0)
1622            };
1623            let at = surface.point_at(line.point(lo).x, line.point(lo).y, tol)?;
1624            let next = surface.point_at(line.point(hi).x, line.point(hi).y, tol)?;
1625            match (surface, line) {
1626                (SurfaceGeometry::Extrusion(e), IsoLine::V(_)) => {
1627                    let shift = Transform::translation(e.direction().vector() * c);
1628                    (e.curve().transformed(&shift, tol)?, 1.0, 0.0)
1629                }
1630                _ => {
1631                    let (curve, length) = straight(at, next - at)?;
1632                    let speed = length / (hi - lo);
1633                    (curve, speed, -speed * lo)
1634                }
1635            }
1636        }
1637        (
1638            SurfaceGeometry::Cylinder(_)
1639            | SurfaceGeometry::Cone(_)
1640            | SurfaceGeometry::Sphere(_)
1641            | SurfaceGeometry::Torus(_)
1642            | SurfaceGeometry::Revolution(_),
1643            IsoLine::V(_),
1644        ) => {
1645            // A parallel: the circle the surface's `u = 0` point turns on.
1646            let axis = match surface {
1647                SurfaceGeometry::Cylinder(s) => frame_axis(s.cylinder().frame()),
1648                SurfaceGeometry::Cone(s) => frame_axis(s.cone().frame()),
1649                SurfaceGeometry::Sphere(s) => frame_axis(s.sphere().frame()),
1650                SurfaceGeometry::Torus(s) => frame_axis(s.torus().frame()),
1651                SurfaceGeometry::Revolution(s) => s.axis(),
1652                _ => return Ok(None),
1653            };
1654            let start = surface.point_at(0.0, c, tol)?;
1655            let centre = axis.project(start);
1656            let radius = start.distance(centre);
1657            if radius <= tol.confusion() {
1658                return Ok(None);
1659            }
1660            (circle(centre, axis.direction, start, radius)?, 1.0, 0.0)
1661        }
1662        (SurfaceGeometry::Sphere(_) | SurfaceGeometry::Torus(_), IsoLine::U(_)) => {
1663            // A meridian or tube circle, its angle the surface's `v`.
1664            let at0 = surface.point_at(c, 0.0, tol)?;
1665            let quarter = surface.point_at(c, core::f64::consts::FRAC_PI_2, tol)?;
1666            let opposite = surface.point_at(c, -core::f64::consts::FRAC_PI_2, tol)?;
1667            let centre = Point::from_vector((quarter.to_vector() + opposite.to_vector()) * 0.5);
1668            let radius = at0.distance(centre);
1669            let z = Direction::new((at0 - centre).cross(quarter - centre), tol)?;
1670            (circle(centre, z, at0, radius)?, 1.0, 0.0)
1671        }
1672        (SurfaceGeometry::Revolution(r), IsoLine::U(_)) => {
1673            let turn = Transform::rotation(r.axis(), c);
1674            (r.curve().transformed(&turn, tol)?, 1.0, 0.0)
1675        }
1676        (SurfaceGeometry::Trimmed(t), _) => return iso_curve(t.basis(), line, probes, span, tol),
1677        // No closed form (an offset of a spline): the iso-curve fitted to
1678        // the surface along the line at the line's own parameters,
1679        // same-parameter with the straight chart line it runs along, and
1680        // held to the confusion distance between its samples as well as at
1681        // them.
1682        _ => {
1683            const N: usize = 128;
1684            let (lo, hi) = span;
1685            if hi <= lo || !hi.is_finite() || !lo.is_finite() {
1686                return Ok(None);
1687            }
1688            #[allow(clippy::cast_precision_loss)]
1689            let params: Vec<f64> = (0..=N)
1690                .map(|i| lo + (hi - lo) * i as f64 / N as f64)
1691                .collect();
1692            let fitted = ogeom_geom::fit::fit_curve_sampled(
1693                |w| {
1694                    let on = line.point(w);
1695                    surface.point_at(on.x, on.y, tol)
1696                },
1697                &params,
1698                false,
1699                3,
1700                tol.confusion(),
1701                tol,
1702            )?;
1703            if !fitted.met {
1704                return Ok(None);
1705            }
1706            (Curve::BSpline(fitted.curve), 1.0, 0.0)
1707        }
1708    };
1709    // The closed forms above assume the surfaces' own conventions. Hold
1710    // them to it.
1711    let (curve, scale, offset) = &found;
1712    for &w in probes {
1713        let on = line.point(w);
1714        let expected = surface.point_at(on.x, on.y, tol)?;
1715        let got = curve.point_at(scale * w + offset, tol)?;
1716        if expected.distance(got) > tol.confusion() {
1717            return Ok(None);
1718        }
1719    }
1720    Ok(Some(found))
1721}
1722
1723fn frame_axis(frame: Frame) -> Axis {
1724    Axis {
1725        location: frame.origin(),
1726        direction: frame.z(),
1727    }
1728}
1729
1730// --- helpers -------------------------------------------------------------------
1731
1732pub(crate) fn forward(shape: &Shape) -> Shape {
1733    if shape.orientation() == Orientation::Reversed {
1734        shape.reversed()
1735    } else {
1736        shape.clone()
1737    }
1738}
1739
1740pub(crate) fn edge_data(model: &Model, edge: &Shape) -> OgeomResult<EdgeData> {
1741    match model.node(edge).map(ogeom_topo::TShape::data) {
1742        Some(NodeData::Edge(data)) => Ok((**data).clone()),
1743        Some(_) => ogeom_bail!(Construction, "expected an edge"),
1744        None => ogeom_bail!(Dangling, "edge is not in this model"),
1745    }
1746}
1747
1748pub(crate) fn face_data(model: &Model, face: &Shape) -> OgeomResult<ogeom_topo::FaceData> {
1749    match model.node(face).map(ogeom_topo::TShape::data) {
1750        Some(NodeData::Face(data)) => Ok((**data).clone()),
1751        Some(_) => ogeom_bail!(Construction, "expected a face"),
1752        None => ogeom_bail!(Dangling, "face is not in this model"),
1753    }
1754}
1755
1756pub(crate) fn vertex_point(model: &Model, vertex: &Shape) -> OgeomResult<Point> {
1757    let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
1758        ogeom_bail!(Construction, "a vertex holds no point");
1759    };
1760    Ok(data.point)
1761}
1762
1763/// The area a ring encloses in its chart, positive counter-clockwise.
1764///
1765/// Summed about the ring's first point: the same sum as about the chart's
1766/// origin, which may stand far off, where it is a difference of huge
1767/// products.
1768pub(crate) fn signed_area(ring: &[Point2]) -> f64 {
1769    let n = ring.len();
1770    let Some(&anchor) = ring.first() else {
1771        return 0.0;
1772    };
1773    (0..n)
1774        .map(|i| (ring[i] - anchor).cross(ring[(i + 1) % n] - anchor))
1775        .sum::<f64>()
1776        * 0.5
1777}
1778
1779/// Even-odd containment against every ring.
1780pub(crate) fn inside(rings: &[Vec<Point2>], p: Point2) -> bool {
1781    let mut odd = false;
1782    for ring in rings {
1783        let n = ring.len();
1784        for i in 0..n {
1785            let (a, b) = (ring[i], ring[(i + 1) % n]);
1786            if (a.y > p.y) != (b.y > p.y) {
1787                let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
1788                if x > p.x {
1789                    odd = !odd;
1790                }
1791            }
1792        }
1793    }
1794    odd
1795}
1796
1797#[cfg(test)]
1798#[allow(clippy::unwrap_used, clippy::expect_used)]
1799mod tests {
1800    use super::*;
1801    use ogeom_geom::{BSplineSurface, OffsetSurface};
1802    use ogeom_math::ControlGrid;
1803
1804    const T: Tolerances = Tolerances::millimetres();
1805
1806    /// A strip one long and a hundredth wide encloses its own area wherever
1807    /// it stands in its chart, up to 2e7 from the chart's origin.
1808    #[test]
1809    fn a_ring_far_out_in_its_chart_encloses_its_own_area() {
1810        for o in [0.0, 1e6, 1e7, 2e7] {
1811            let (x, y) = (o, -0.7 * o);
1812            let ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 0.01), (0.0, 0.01)]
1813                .map(|(dx, dy)| Point2::new(x + dx, y + dy));
1814            let area = signed_area(&ring);
1815            assert!((area - 0.01).abs() < 1e-9, "o {o:e}: area {area}");
1816        }
1817    }
1818
1819    /// An iso-curve with no closed form keeps to the surface between the
1820    /// points it was fitted at. The surface is the offset of a patch
1821    /// carrying a ridge about as wide as a hundred and twenty-eighth of the
1822    /// line, so a spline through the line's even samples swings off the
1823    /// ridge between them; the curve stands within the confusion distance
1824    /// of the surface everywhere along the line.
1825    #[test]
1826    fn an_iso_curve_without_a_closed_form_keeps_to_the_surface_between_its_samples() {
1827        const ROWS: u32 = 401;
1828        let mut control = Vec::new();
1829        for i in 0..ROWS {
1830            let x = f64::from(i) / f64::from(ROWS - 1);
1831            let ridge = 0.05 * (-((x - 0.503) / 0.006).powi(2)).exp();
1832            control.extend([Point::new(x, 0.0, ridge), Point::new(x, 1.0, ridge)]);
1833        }
1834        let mut u_knots = vec![0.0; 4];
1835        u_knots.extend((1..ROWS - 3).map(|k| f64::from(k) / f64::from(ROWS - 3)));
1836        u_knots.extend([1.0; 4]);
1837        let patch = BSplineSurface::new(
1838            KnotVector::new(u_knots, 3).unwrap(),
1839            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1840            &ControlGrid::new(control, ROWS as usize, 2).unwrap(),
1841            T,
1842        )
1843        .unwrap();
1844        let surface =
1845            SurfaceGeometry::Offset(Box::new(OffsetSurface::new(patch.into(), 5e-4).unwrap()));
1846        let line = IsoLine::V(0.5);
1847        let (curve, scale, offset) = iso_curve(&surface, line, &[0.5], (0.0, 1.0), T)
1848            .unwrap()
1849            .expect("the ridge is followed within the confusion distance");
1850        let mut worst = 0.0_f64;
1851        for k in 0..=20_000 {
1852            let w = f64::from(k) / 20_000.0;
1853            let on = line.point(w);
1854            let expected = surface.point_at(on.x, on.y, T).unwrap();
1855            let got = curve.point_at(scale * w + offset, T).unwrap();
1856            worst = worst.max(expected.distance(got));
1857        }
1858        assert!(
1859            worst <= 2.0 * T.confusion(),
1860            "the iso-curve leaves the surface by {worst:.3e}"
1861        );
1862    }
1863}