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 std::collections::{HashMap, HashSet};
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, Location, Model, NodeData, Orientation, Shape, ShapeType, SurfaceId,
29    TShapeId, VertexData, 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/// # Errors
224///
225/// As [`ogeom_algo::to_nurbs`] and [`divide_by_continuity`].
226pub fn to_bezier(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
227    let nurbs = ogeom_algo::to_nurbs(model, shape, tol)?;
228    let divided = divide_by_continuity(model, &nurbs.shape, Continuity::CInfinity, tol)?;
229
230    // Each edge's curve restated as its span.
231    let mut reshape = Reshape::new();
232    for edge in explore_unique(model, &divided.shape, ShapeType::Edge)? {
233        let data = edge_data(model, &edge)?;
234        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d().cloned() else {
235            continue;
236        };
237        let Some(Curve::BSpline(spline)) = model.geometry().curve(curve).cloned() else {
238            continue;
239        };
240        if spline.knots().distinct().len() <= 2 {
241            continue;
242        }
243        let span = model
244            .geometry_mut()
245            .add_curve(Curve::BSpline(spline.segment(range, tol)?));
246        let mut fresh = data.clone();
247        for repr in &mut fresh.representations {
248            if let EdgeRepr::Curve3d { curve, .. } = repr {
249                *curve = span;
250            }
251        }
252        let bounds = model.children_of(&forward(&edge))?;
253        let rebuilt = model.add_edge(fresh, &bounds)?;
254        reshape.replace(&forward(&edge), rebuilt);
255    }
256    let spans = if reshape.is_empty() {
257        Built::new(divided.shape.clone(), History::identity())
258    } else {
259        reshape.apply(model, &divided.shape)?
260    };
261
262    // Each face's surface restated as its patch, its edges' trims kept
263    // under the patch's id: the patch keeps the parameters they are in.
264    let mut reshape = Reshape::new();
265    for face in explore_unique(model, &spans.shape, ShapeType::Face)? {
266        let data = face_data(model, &face)?;
267        let Some(SurfaceGeometry::BSpline(spline)) =
268            model.geometry().surface(data.surface).cloned()
269        else {
270            continue;
271        };
272        if spline.u_knots().distinct().len() <= 2 && spline.v_knots().distinct().len() <= 2 {
273            continue;
274        }
275        let rings = rings(model, &forward(&face), data.surface, tol)?;
276        let ((u0, u1), (v0, v1)) = chart_bounds(&rings, tol);
277        let ((du0, du1), (dv0, dv1)) = spline.domain();
278        let patch = spline.segment((u0.max(du0), u1.min(du1)), (v0.max(dv0), v1.min(dv1)), tol)?;
279        let id = model
280            .geometry_mut()
281            .add_surface(SurfaceGeometry::BSpline(patch));
282        for edge in explore_unique(model, &face, ShapeType::Edge)? {
283            let repr = edge_data(model, &edge)?
284                .pcurve_for(data.surface, edge.location())
285                .cloned();
286            let Some(mut repr) = repr else { continue };
287            match &mut repr {
288                EdgeRepr::PCurve { surface, .. } | EdgeRepr::Seam { surface, .. } => *surface = id,
289                _ => continue,
290            }
291            let Some(node) = model.node_mut(&edge) else {
292                ogeom_bail!(Dangling, "edge is not in this model");
293            };
294            if let NodeData::Edge(e) = node.data_mut() {
295                let agreed = e.same_parameter();
296                e.add(repr);
297                e.assert_same_parameter(agreed);
298            }
299        }
300        let mut fresh = data.clone();
301        fresh.surface = id;
302        fresh.triangulation = None;
303        let wires = model.children_of(&forward(&face))?;
304        let rebuilt = model.add_face(fresh, &wires)?;
305        reshape.replace(&forward(&face), rebuilt);
306    }
307    let patches = if reshape.is_empty() {
308        Built::new(spans.shape.clone(), History::identity())
309    } else {
310        reshape.apply(model, &spans.shape)?
311    };
312    Ok(Built::new(
313        patches.shape,
314        nurbs
315            .history
316            .then(&divided.history)
317            .then(&spans.history)
318            .then(&patches.history),
319    ))
320}
321
322// --- drivers ---------------------------------------------------------------
323
324type Bounds = ((f64, f64), (f64, f64));
325
326/// Cut faces until `lines` asks for no cut any face takes: each round asks
327/// every face not yet settled for the lines it wants, in order, and makes
328/// the first that crosses it.
329fn divide_faces(
330    model: &mut Model,
331    shape: &Shape,
332    tol: Tolerances,
333    mut lines: impl FnMut(&Model, &Shape, &SurfaceGeometry, Bounds) -> OgeomResult<Vec<IsoLine>>,
334) -> OgeomResult<Built> {
335    let mut current = Built::new(shape.clone(), History::identity());
336    let mut settled: HashSet<TShapeId> = HashSet::new();
337    'rounds: for _ in 0..100_000 {
338        for face in explore_unique(model, &current.shape, ShapeType::Face)? {
339            if settled.contains(&face.node()) {
340                continue;
341            }
342            let data = face_data(model, &face)?;
343            if data.natural_restriction {
344                // Bounded first, by its chart's own sides: then it cuts as
345                // any face does.
346                match bounded_natural(model, &face, tol)? {
347                    Some(bounded) => {
348                        let mut reshape = Reshape::new();
349                        reshape.replace(&forward(&face), bounded);
350                        let next = reshape.apply(model, &current.shape)?;
351                        current = Built::new(next.shape, current.history.then(&next.history));
352                        continue 'rounds;
353                    }
354                    None => {
355                        settled.insert(face.node());
356                        continue;
357                    }
358                }
359            }
360            let Some(surface) = model.geometry().surface(data.surface).cloned() else {
361                ogeom_bail!(Dangling, "surface is not in this model");
362            };
363            let rings = rings(model, &forward(&face), data.surface, tol)?;
364            let bounds = chart_bounds(&rings, tol);
365            for line in lines(&*model, &face, &surface, bounds)? {
366                let (lo, hi) = match line {
367                    IsoLine::U(_) => bounds.0,
368                    IsoLine::V(_) => bounds.1,
369                };
370                let margin = (hi - lo).abs() * 1e-7 + tol.parametric();
371                if line.at() <= lo + margin || line.at() >= hi - margin {
372                    continue;
373                }
374                if let Some(reshape) = cut_face(model, &face, line, tol)? {
375                    let next = reshape.apply(model, &current.shape)?;
376                    current = Built::new(next.shape, current.history.then(&next.history));
377                    continue 'rounds;
378                }
379            }
380            settled.insert(face.node());
381        }
382        return Ok(current);
383    }
384    ogeom_bail!(Construction, "the division did not settle");
385}
386
387/// A face covering its surface's whole chart, rebuilt with that chart's
388/// sides as its boundary: a periodic direction's two sides one seam edge,
389/// a side the surface pinches to a point (a ball's pole) a degenerate edge.
390/// `None` where the chart is unbounded or a side has no closed-form curve.
391fn bounded_natural(model: &mut Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Shape>> {
392    let face_fwd = forward(face);
393    let data = face_data(model, &face_fwd)?;
394    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
395        ogeom_bail!(Dangling, "surface is not in this model");
396    };
397    let ((u0, u1), (v0, v1)) = surface.domain();
398    if ![u0, u1, v0, v1]
399        .iter()
400        .all(|x| x.is_finite() && x.abs() < 1e6)
401    {
402        return Ok(None);
403    }
404    let (pu, pv) = (surface.is_periodic_u(), surface.is_periodic_v());
405    let corner = |u: f64, v: f64| surface.point_at(u, v, tol);
406    // Each side as (line, from, to) in the free parameter, walked so the
407    // ring runs counter-clockwise round the chart.
408    let sides = [
409        (IsoLine::V(v0), u0, u1),
410        (IsoLine::U(u1), v0, v1),
411        (IsoLine::V(v1), u1, u0),
412        (IsoLine::U(u0), v1, v0),
413    ];
414    // Vertices by chart corner, one where the corners meet in space.
415    let corners = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
416    let mut vertices: Vec<Shape> = Vec::with_capacity(4);
417    for (i, (u, v)) in corners.iter().enumerate() {
418        let p = corner(*u, *v)?;
419        let found = (0..i).find(|j| {
420            let (a, b) = corners[*j];
421            corner(a, b).is_ok_and(|q| q.distance(p) <= tol.confusion())
422        });
423        vertices.push(match found {
424            Some(j) => vertices[j].clone(),
425            None => model.add_vertex(VertexData::new(p)),
426        });
427    }
428    let chart_line = |line: IsoLine, a: f64, b: f64, t: (f64, f64)| -> OgeomResult<PlanarCurve> {
429        let knots = KnotVector::new(vec![t.0, t.0, t.1, t.1], 1)?;
430        let (p, q) = (line.point(a), line.point(b));
431        let (p, q) = if t.0 <= t.1 { (p, q) } else { (q, p) };
432        Ok(PlanarCurve::BSpline(BSpline2d::new(
433            knots,
434            vec![p, q],
435            tol,
436        )?))
437    };
438    // One edge per side, a periodic pair shared; each side's occurrence.
439    let mut ring: Vec<Shape> = Vec::with_capacity(4);
440    let mut seam_u: Option<Shape> = None;
441    let mut seam_v: Option<Shape> = None;
442    for (index, (line, a, b)) in sides.into_iter().enumerate() {
443        let (from, to) = (vertices[index].clone(), vertices[(index + 1) % 4].clone());
444        let periodic_pair = match line {
445            IsoLine::U(_) => pu,
446            IsoLine::V(_) => pv,
447        };
448        // The second side of a periodic pair is the first's seam, walked
449        // back.
450        if periodic_pair {
451            let held = match line {
452                IsoLine::U(_) => &seam_u,
453                IsoLine::V(_) => &seam_v,
454            };
455            if let Some(seam) = held {
456                ring.push(seam.reversed());
457                continue;
458            }
459        }
460        let (lo, hi) = (a.min(b), a.max(b));
461        let mid = line.point(0.5 * (lo + hi));
462        let pinched = corner(line.point(lo).x, line.point(lo).y)?.distance(corner(mid.x, mid.y)?)
463            <= tol.confusion()
464            && corner(line.point(hi).x, line.point(hi).y)?.distance(corner(mid.x, mid.y)?)
465                <= tol.confusion();
466        let edge = if pinched {
467            let mut edge_data = EdgeData::new();
468            edge_data.degenerate = true;
469            let trim = chart_line(line, lo, hi, (lo, hi))?;
470            let id = model.geometry_mut().add_pcurve(trim);
471            edge_data.add(EdgeRepr::PCurve {
472                curve: id,
473                surface: data.surface,
474                location: Location::identity(),
475                range: (lo, hi),
476            });
477            let pole = model.add_edge(edge_data, &[from.clone(), from.clone()])?;
478            // Its trim runs up the chart; the side may walk it down.
479            if a <= b { pole } else { pole.reversed() }
480        } else {
481            let Some((curve, scale, offset)) =
482                iso_curve(&surface, line, &[0.5 * (lo + hi)], (lo, hi), tol)?
483            else {
484                return Ok(None);
485            };
486            let (t_lo, t_hi) = (scale * lo + offset, scale * hi + offset);
487            let (range, rising) = if t_lo <= t_hi {
488                ((t_lo, t_hi), true)
489            } else {
490                ((t_hi, t_lo), false)
491            };
492            let curve_id = model.geometry_mut().add_curve(curve);
493            let mut edge_data = EdgeData::on_curve(curve_id, Location::identity(), range);
494            // The edge runs with its curve; the side walks from `a` to `b`.
495            let (start_v, end_v) = {
496                let low_end = if a <= b { &from } else { &to };
497                let high_end = if a <= b { &to } else { &from };
498                if rising {
499                    (low_end.clone(), high_end.clone())
500                } else {
501                    (high_end.clone(), low_end.clone())
502                }
503            };
504            let pcurve_at = |line: IsoLine| -> OgeomResult<PlanarCurve> {
505                let (p, q) = if rising {
506                    (line.point(lo), line.point(hi))
507                } else {
508                    (line.point(hi), line.point(lo))
509                };
510                let knots = KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
511                Ok(PlanarCurve::BSpline(BSpline2d::new(
512                    knots,
513                    vec![p, q],
514                    tol,
515                )?))
516            };
517            if periodic_pair {
518                // Its two chart sides: this one, and the one a period on.
519                let other = match line {
520                    IsoLine::U(c) => IsoLine::U(if (c - u1).abs() < (c - u0).abs() {
521                        u0
522                    } else {
523                        u1
524                    }),
525                    IsoLine::V(c) => IsoLine::V(if (c - v1).abs() < (c - v0).abs() {
526                        v0
527                    } else {
528                        v1
529                    }),
530                };
531                let here = model.geometry_mut().add_pcurve(pcurve_at(line)?);
532                let there = model.geometry_mut().add_pcurve(pcurve_at(other)?);
533                edge_data.add(EdgeRepr::Seam {
534                    forward: here,
535                    reversed: there,
536                    surface: data.surface,
537                    location: Location::identity(),
538                    range,
539                });
540            } else {
541                let id = model.geometry_mut().add_pcurve(pcurve_at(line)?);
542                edge_data.add(EdgeRepr::PCurve {
543                    curve: id,
544                    surface: data.surface,
545                    location: Location::identity(),
546                    range,
547                });
548            }
549            edge_data.assert_same_parameter(true);
550            let edge = model.add_edge(edge_data, &[start_v, end_v])?;
551            let walked_with_curve = (a <= b) == rising;
552            let occurrence = if walked_with_curve {
553                edge.clone()
554            } else {
555                edge.reversed()
556            };
557            if periodic_pair {
558                match line {
559                    IsoLine::U(_) => seam_u = Some(occurrence.clone()),
560                    IsoLine::V(_) => seam_v = Some(occurrence.clone()),
561                }
562            }
563            ring.push(occurrence);
564            continue;
565        };
566        ring.push(edge);
567    }
568    let wire = model.add_wire(&ring)?;
569    let mut fresh = data.clone();
570    fresh.natural_restriction = false;
571    fresh.triangulation = None;
572    let bounded = model.add_face(fresh, &[wire])?;
573    Ok(Some(bounded))
574}
575
576/// Cut every edge whose spline is less than `order` times differentiable at
577/// an interior knot, there.
578fn divide_edges(
579    model: &mut Model,
580    shape: &Shape,
581    order: usize,
582    tol: Tolerances,
583) -> OgeomResult<Built> {
584    let mut reshape = Reshape::new();
585    for edge in explore_unique(model, shape, ShapeType::Edge)? {
586        let edge = forward(&edge);
587        let data = edge_data(model, &edge)?;
588        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d().cloned() else {
589            continue;
590        };
591        let Some(geometry) = model.geometry().curve(curve) else {
592            ogeom_bail!(Dangling, "curve is not in this model");
593        };
594        let margin = (range.1 - range.0) * 1e-9 + tol.parametric();
595        let at: Vec<f64> = curve_knots(geometry)
596            .map(|knots| weak_knots(&knots, order))
597            .unwrap_or_default()
598            .into_iter()
599            .filter(|t| *t > range.0 + margin && *t < range.1 - margin)
600            .collect();
601        if at.is_empty() {
602            continue;
603        }
604        let fractions: Vec<f64> = at
605            .iter()
606            .map(|t| (t - range.0) / (range.1 - range.0))
607            .collect();
608        let (pieces, _) = split_edge(model, &edge, &fractions, tol)?;
609        reshape.split(&edge, pieces);
610    }
611    if reshape.is_empty() {
612        return Ok(Built::new(shape.clone(), History::identity()));
613    }
614    reshape.apply(model, shape)
615}
616
617/// The shape, its placements baked in where any edge or face is placed:
618/// cuts make new nodes in the chart of the node they cut, and a node placed
619/// twice has two.
620fn placed_baked(model: &mut Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Built> {
621    let mut placed = false;
622    for kind in [ShapeType::Edge, ShapeType::Face] {
623        for s in explore_unique(model, shape, kind)? {
624            placed |= !s.location().is_identity();
625        }
626    }
627    for face in explore_unique(model, shape, ShapeType::Face)? {
628        placed |= !face_data(model, &face)?.location.is_identity();
629    }
630    if placed {
631        ogeom_algo::baked_shape(model, shape, tol)
632    } else {
633        Ok(Built::new(shape.clone(), History::identity()))
634    }
635}
636
637// --- where to cut -----------------------------------------------------------
638
639fn curve_knots(curve: &Curve) -> Option<KnotVector> {
640    match curve {
641        Curve::BSpline(b) => Some(b.knots().clone()),
642        Curve::Trimmed(t) => curve_knots(t.basis()),
643        _ => None,
644    }
645}
646
647/// Interior knots where the spline is less than `order` times
648/// differentiable.
649fn weak_knots(knots: &KnotVector, order: usize) -> Vec<f64> {
650    let (a, b) = knots.domain();
651    let p = knots.degree();
652    knots
653        .distinct()
654        .into_iter()
655        .filter(|(value, m)| *value > a && *value < b && p.saturating_sub(*m) < order)
656        .map(|(value, _)| value)
657        .collect()
658}
659
660/// The knot lines of a surface where it is less than `order` times
661/// differentiable.
662fn knot_lines(surface: &SurfaceGeometry, order: usize) -> Vec<IsoLine> {
663    match surface {
664        SurfaceGeometry::BSpline(b) => weak_knots(b.u_knots(), order)
665            .into_iter()
666            .map(IsoLine::U)
667            .chain(weak_knots(b.v_knots(), order).into_iter().map(IsoLine::V))
668            .collect(),
669        SurfaceGeometry::Extrusion(e) => curve_knots(e.curve())
670            .map(|k| weak_knots(&k, order).into_iter().map(IsoLine::U).collect())
671            .unwrap_or_default(),
672        SurfaceGeometry::Revolution(r) => curve_knots(r.curve())
673            .map(|k| weak_knots(&k, order).into_iter().map(IsoLine::V).collect())
674            .unwrap_or_default(),
675        SurfaceGeometry::Trimmed(t) => knot_lines(t.basis(), order),
676        // An offset is one order rougher than its basis at a knot.
677        SurfaceGeometry::Offset(o) => knot_lines(o.basis(), order.saturating_add(1)),
678        _ => Vec::new(),
679    }
680}
681
682/// Whether each parameter of a surface is an angle.
683const fn angular(surface: &SurfaceGeometry) -> (bool, bool) {
684    match surface {
685        SurfaceGeometry::Cylinder(_)
686        | SurfaceGeometry::Cone(_)
687        | SurfaceGeometry::Revolution(_) => (true, false),
688        SurfaceGeometry::Sphere(_) | SurfaceGeometry::Torus(_) => (true, true),
689        _ => (false, false),
690    }
691}
692
693// --- one cut ------------------------------------------------------------------
694
695/// One traversal of an edge around a face, in the chart.
696#[derive(Debug, Clone)]
697struct Occurrence {
698    edge: Shape,
699    pcurve: PlanarCurve,
700    range: (f64, f64),
701}
702
703impl Occurrence {
704    fn reversed(&self) -> bool {
705        self.edge.orientation() == Orientation::Reversed
706    }
707
708    /// The chart point a fraction `s` of the way along the traversal.
709    fn at(&self, s: f64, tol: Tolerances) -> OgeomResult<Point2> {
710        let s = if self.reversed() { 1.0 - s } else { s };
711        self.pcurve
712            .point_at(self.range.0 + (self.range.1 - self.range.0) * s, tol)
713    }
714
715    fn polyline(&self, samples: usize, tol: Tolerances) -> OgeomResult<Vec<Point2>> {
716        (0..=samples)
717            .map(|i| {
718                #[allow(clippy::cast_precision_loss)]
719                let s = i as f64 / samples as f64;
720                self.at(s, tol)
721            })
722            .collect()
723    }
724}
725
726/// An edge's trim on a face: one pcurve, or a seam's two, over a range.
727type Trims = (PlanarCurve, Option<PlanarCurve>, (f64, f64));
728
729/// A ring's edges, its chart outline, and the holes it holds.
730type Outer = (Vec<Shape>, Vec<Point2>, Vec<Vec<Shape>>);
731
732/// A face's rings as traversals, each seam traversal on the side of the
733/// chart its ring continues on.
734fn rings(
735    model: &Model,
736    face: &Shape,
737    surface: SurfaceId,
738    tol: Tolerances,
739) -> OgeomResult<Vec<Vec<Occurrence>>> {
740    let mut out = Vec::new();
741    for wire in model.ordered_children_of(face)? {
742        let edges = model.ordered_children_of(&wire)?;
743        let mut choices: Vec<Option<Trims>> = Vec::new();
744        for edge in &edges {
745            let data = edge_data(model, edge)?;
746            let pcurve =
747                |id| -> OgeomResult<PlanarCurve> {
748                    model.geometry().pcurve(id).cloned().ok_or_else(|| {
749                        ogeom_core::ogeom_err!(Dangling, "pcurve is not in this model")
750                    })
751                };
752            choices.push(match data.pcurve_for(surface, edge.location()) {
753                Some(EdgeRepr::PCurve { curve, range, .. }) => {
754                    Some((pcurve(*curve)?, None, *range))
755                }
756                Some(EdgeRepr::Seam {
757                    forward,
758                    reversed,
759                    range,
760                    ..
761                }) => Some((pcurve(*forward)?, Some(pcurve(*reversed)?), *range)),
762                _ => None,
763            });
764        }
765        // Walked from an edge off the seam where there is one; a ring of
766        // seams alone (a torus's) starts on the side its first occurrence's
767        // orientation names.
768        let first_plain = choices
769            .iter()
770            .position(|c| c.as_ref().is_some_and(|c| c.1.is_none()))
771            .unwrap_or(0);
772        let n = edges.len();
773        let mut ring: Vec<Option<Occurrence>> = vec![None; n];
774        let mut last: Option<Point2> = None;
775        for k in 0..n {
776            let i = (first_plain + k) % n;
777            let Some((a, b, range)) = choices[i].clone() else {
778                ogeom_bail!(Construction, "an edge has no trim on this face");
779            };
780            let mut occurrence = Occurrence {
781                edge: edges[i].clone(),
782                pcurve: a,
783                range,
784            };
785            if let Some(b) = b {
786                let other = Occurrence {
787                    pcurve: b,
788                    ..occurrence.clone()
789                };
790                let take_other = match last {
791                    Some(last) => {
792                        other.at(0.0, tol)?.distance(last) < occurrence.at(0.0, tol)?.distance(last)
793                    }
794                    None => occurrence.reversed(),
795                };
796                if take_other {
797                    occurrence = other;
798                }
799            }
800            last = Some(occurrence.at(1.0, tol)?);
801            ring[i] = Some(occurrence);
802        }
803        out.push(ring.into_iter().flatten().collect());
804    }
805    Ok(out)
806}
807
808fn chart_bounds(rings: &[Vec<Occurrence>], tol: Tolerances) -> Bounds {
809    let mut b = (
810        (f64::INFINITY, f64::NEG_INFINITY),
811        (f64::INFINITY, f64::NEG_INFINITY),
812    );
813    for o in rings.iter().flatten() {
814        for i in 0..=32 {
815            if let Ok(p) = o.at(f64::from(i) / 32.0, tol) {
816                b.0 = (b.0.0.min(p.x), b.0.1.max(p.x));
817                b.1 = (b.1.0.min(p.y), b.1.1.max(p.y));
818            }
819        }
820    }
821    b
822}
823
824/// Where a line meets a face's boundary.
825#[derive(Debug, Clone, Copy)]
826enum Meeting {
827    /// At an existing vertex.
828    Vertex(TShapeId),
829    /// Inside an edge, a fraction of the way along it (forward).
830    Inside(TShapeId, u64),
831}
832
833/// The substitutions that cut `face` along `line`, or `None` where the
834/// line does not cross it.
835#[allow(clippy::too_many_lines)]
836fn cut_face(
837    model: &mut Model,
838    face: &Shape,
839    line: IsoLine,
840    tol: Tolerances,
841) -> OgeomResult<Option<Reshape>> {
842    let face_fwd = forward(face);
843    let data = face_data(model, &face_fwd)?;
844    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
845        ogeom_bail!(Dangling, "surface is not in this model");
846    };
847    let rings = rings(model, &face_fwd, data.surface, tol)?;
848    let bounds = chart_bounds(&rings, tol);
849    let span = (bounds.0.1 - bounds.0.0).max(bounds.1.1 - bounds.1.0);
850    let eps = span * 1e-9 + tol.parametric();
851    let c = line.at();
852
853    // Where the line crosses each edge, as fractions of the edge's forward
854    // range; and every point where it meets the boundary, along the line.
855    let mut cuts: HashMap<TShapeId, Vec<f64>> = HashMap::new();
856    let mut meetings: Vec<(f64, Meeting)> = Vec::new();
857    for occurrence in rings.iter().flatten() {
858        let f = |s: f64| -> OgeomResult<f64> { Ok(line.fixed(occurrence.at(s, tol)?) - c) };
859        const N: usize = 64;
860        #[allow(clippy::cast_precision_loss)]
861        let values: Vec<f64> = (0..=N)
862            .map(|i| f(i as f64 / N as f64))
863            .collect::<OgeomResult<_>>()?;
864        if values.iter().all(|v| v.abs() <= eps) {
865            ogeom_bail!(Construction, "an edge of the face runs along {line:?}");
866        }
867        let end = occurrence.at(1.0, tol)?;
868        if (line.fixed(end) - c).abs() <= eps {
869            let Some((_, v)) = edge_vertices(model, &occurrence.edge)? else {
870                continue;
871            };
872            meetings.push((line.free(end), Meeting::Vertex(v.node())));
873        }
874        for i in 0..N {
875            let (a, b) = (values[i], values[i + 1]);
876            if a.abs() <= eps || b.abs() <= eps || a.signum() == b.signum() {
877                // Through a sample exactly: a crossing there only where the
878                // sign changes across it.
879                if i + 1 < N
880                    && b.abs() <= eps
881                    && a.abs() > eps
882                    && values[i + 2].abs() > eps
883                    && a.signum() != values[i + 2].signum()
884                {
885                    #[allow(clippy::cast_precision_loss)]
886                    let s = (i + 1) as f64 / N as f64;
887                    record(occurrence, s, line, &mut cuts, &mut meetings, tol)?;
888                }
889                continue;
890            }
891            #[allow(clippy::cast_precision_loss)]
892            let (mut lo, mut hi) = (i as f64 / N as f64, (i + 1) as f64 / N as f64);
893            let mut f_lo = a;
894            for _ in 0..80 {
895                let mid = 0.5 * (lo + hi);
896                let f_mid = f(mid)?;
897                if f_mid.signum() == f_lo.signum() {
898                    (lo, f_lo) = (mid, f_mid);
899                } else {
900                    hi = mid;
901                }
902            }
903            record(
904                occurrence,
905                0.5 * (lo + hi),
906                line,
907                &mut cuts,
908                &mut meetings,
909                tol,
910            )?;
911        }
912    }
913    meetings.sort_by(|a, b| a.0.total_cmp(&b.0));
914    meetings.dedup_by(|b, a| {
915        (a.0 - b.0).abs() <= eps
916            && match (a.1, b.1) {
917                (Meeting::Vertex(x), Meeting::Vertex(y)) => x == y,
918                (Meeting::Inside(x, s), Meeting::Inside(y, t)) => x == y && s == t,
919                _ => false,
920            }
921    });
922
923    // The stretches of the line inside the face.
924    let outlines: Vec<Vec<Point2>> = rings
925        .iter()
926        .map(|ring| -> OgeomResult<Vec<Point2>> {
927            let mut out = Vec::new();
928            for o in ring {
929                out.extend(o.polyline(32, tol)?);
930            }
931            Ok(out)
932        })
933        .collect::<OgeomResult<_>>()?;
934    let mut stretches: Vec<(usize, usize)> = Vec::new();
935    for i in 0..meetings.len().saturating_sub(1) {
936        let (w0, w1) = (meetings[i].0, meetings[i + 1].0);
937        if w1 - w0 <= eps * 10.0 {
938            continue;
939        }
940        if inside(&outlines, line.point(0.5 * (w0 + w1))) {
941            stretches.push((i, i + 1));
942        }
943    }
944    if stretches.is_empty() {
945        return Ok(None);
946    }
947    let outer_sign = outlines
948        .iter()
949        .map(|o| signed_area(o))
950        .max_by(|a, b| a.abs().total_cmp(&b.abs()))
951        .unwrap_or(1.0)
952        .signum();
953
954    // Cut the edges, and every meeting inside one becomes its new vertex.
955    let mut reshape = Reshape::new();
956    let mut pieces_of: HashMap<TShapeId, Vec<(Shape, f64, f64)>> = HashMap::new();
957    let mut vertex_at: HashMap<(TShapeId, u64), Shape> = HashMap::new();
958    let mut vertex_of_node: HashMap<TShapeId, Shape> = HashMap::new();
959    for v in explore_unique(model, &face_fwd, ShapeType::Vertex)? {
960        vertex_of_node.insert(v.node(), v);
961    }
962    for occurrence in rings.iter().flatten() {
963        let edge = forward(&occurrence.edge);
964        if pieces_of.contains_key(&edge.node()) {
965            continue;
966        }
967        let Some(fractions) = cuts.get(&edge.node()) else {
968            continue;
969        };
970        let mut fractions = fractions.clone();
971        fractions.sort_by(f64::total_cmp);
972        fractions.dedup_by(|b, a| (*a - *b).abs() <= 1e-12);
973        let (pieces, vertices) = split_edge(model, &edge, &fractions, tol)?;
974        for (s, v) in fractions.iter().zip(&vertices) {
975            vertex_at.insert((edge.node(), s.to_bits()), v.clone());
976        }
977        let mut bounds = vec![0.0];
978        bounds.extend(&fractions);
979        bounds.push(1.0);
980        pieces_of.insert(
981            edge.node(),
982            pieces
983                .iter()
984                .zip(bounds.windows(2))
985                .map(|(p, w)| (p.clone(), w[0], w[1]))
986                .collect(),
987        );
988        reshape.split(&edge, pieces);
989    }
990    let meeting_vertex = |m: Meeting| -> OgeomResult<Shape> {
991        match m {
992            Meeting::Vertex(node) => vertex_of_node
993                .get(&node)
994                .cloned()
995                .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a meeting vertex is lost")),
996            Meeting::Inside(edge, s) => vertex_at
997                .get(&(edge, s))
998                .cloned()
999                .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a cut vertex is lost")),
1000        }
1001    };
1002
1003    // The boundary's pieces, each on its side of the line.
1004    let mut sides: [Vec<Item>; 2] = [Vec::new(), Vec::new()];
1005    for occurrence in rings.iter().flatten() {
1006        let edge = forward(&occurrence.edge);
1007        let parts: Vec<(Shape, f64, f64)> = match pieces_of.get(&edge.node()) {
1008            Some(parts) => parts.clone(),
1009            None => vec![(edge.clone(), 0.0, 1.0)],
1010        };
1011        let mut ordered: Vec<(Shape, f64, f64)> = if occurrence.reversed() {
1012            parts
1013                .into_iter()
1014                .rev()
1015                .map(|(p, a, b)| (p.reversed(), 1.0 - b, 1.0 - a))
1016                .collect()
1017        } else {
1018            parts
1019        };
1020        for (piece, a, b) in ordered.drain(..) {
1021            let sub = |s: f64| occurrence.at(a + (b - a) * s, tol);
1022            let polyline: Vec<Point2> = (0..=16)
1023                .map(|i| sub(f64::from(i) / 16.0))
1024                .collect::<OgeomResult<_>>()?;
1025            let side = usize::from(line.fixed(polyline[8]) > c);
1026            let Some((from, to)) = edge_vertices(model, &piece)? else {
1027                ogeom_bail!(Construction, "a boundary piece has no vertices");
1028            };
1029            sides[side].push(Item {
1030                edge: piece,
1031                from: from.node(),
1032                to: to.node(),
1033                polyline,
1034            });
1035        }
1036    }
1037
1038    // The new edges along the line, each walked one way by each side.
1039    let probes: Vec<f64> = stretches
1040        .iter()
1041        .map(|&(i, j)| 0.5 * (meetings[i].0 + meetings[j].0))
1042        .collect();
1043    let span = (
1044        meetings.first().map_or(0.0, |m| m.0),
1045        meetings.last().map_or(0.0, |m| m.0),
1046    );
1047    let Some((curve, scale, offset)) = iso_curve(&surface, line, &probes, span, tol)? else {
1048        ogeom_bail!(
1049            Construction,
1050            "the surface has no closed-form iso-curve to cut along"
1051        );
1052    };
1053    for (i, j) in stretches {
1054        let (w0, w1) = (meetings[i].0, meetings[j].0);
1055        let (a, b) = (
1056            meeting_vertex(meetings[i].1)?,
1057            meeting_vertex(meetings[j].1)?,
1058        );
1059        let (t0, t1) = (scale * w0 + offset, scale * w1 + offset);
1060        let rising = t1 > t0;
1061        let (range, (start, end), (p0, p1)) = if rising {
1062            ((t0, t1), (&a, &b), (line.point(w0), line.point(w1)))
1063        } else {
1064            ((t1, t0), (&b, &a), (line.point(w1), line.point(w0)))
1065        };
1066        let curve_id = model.geometry_mut().add_curve(curve.clone());
1067        let mut edge_data = EdgeData::on_curve(curve_id, Location::identity(), range);
1068        let trim = BSpline2d::new(
1069            KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?,
1070            vec![p0, p1],
1071            tol,
1072        )?;
1073        let trim_id = model.geometry_mut().add_pcurve(PlanarCurve::BSpline(trim));
1074        edge_data.add(EdgeRepr::PCurve {
1075            curve: trim_id,
1076            surface: data.surface,
1077            location: Location::identity(),
1078            range,
1079        });
1080        edge_data.assert_same_parameter(true);
1081        // The vertices were placed by the edges they cut; the new edge's
1082        // ends reach them within the gap.
1083        let reach = [(start, range.0), (end, range.1)]
1084            .iter()
1085            .map(|(v, t)| -> OgeomResult<f64> {
1086                Ok(vertex_point(model, v)?.distance(curve.point_at(*t, tol)?))
1087            })
1088            .collect::<OgeomResult<Vec<f64>>>()?
1089            .into_iter()
1090            .fold(0.0_f64, f64::max);
1091        edge_data.widen(ogeom_core::Tolerance::new(reach + tol.confusion())?);
1092        let edge = model.add_edge(edge_data, &[start.clone(), end.clone()])?;
1093        let polyline: Vec<Point2> = (0..=16)
1094            .map(|k| {
1095                let w = w0 + (w1 - w0) * f64::from(k) / 16.0;
1096                line.point(w)
1097            })
1098            .collect();
1099        // Walked up the line (increasing free parameter), the material on
1100        // the left is the low side of a `u` line and the high side of a `v`
1101        // line, for a boundary wound counter-clockwise.
1102        let left = match line {
1103            IsoLine::U(_) => 0,
1104            IsoLine::V(_) => 1,
1105        };
1106        let up_side = if outer_sign >= 0.0 { left } else { 1 - left };
1107        let up = if rising {
1108            edge.clone()
1109        } else {
1110            edge.reversed()
1111        };
1112        let down = up.reversed();
1113        sides[up_side].push(Item {
1114            edge: up,
1115            from: a.node(),
1116            to: b.node(),
1117            polyline: polyline.clone(),
1118        });
1119        sides[1 - up_side].push(Item {
1120            edge: down,
1121            from: b.node(),
1122            to: a.node(),
1123            polyline: polyline.into_iter().rev().collect(),
1124        });
1125    }
1126
1127    // Each side's pieces chained into rings, and the rings into faces.
1128    let mut faces: Vec<Shape> = Vec::new();
1129    for items in sides {
1130        let loops = chain(items, eps.max(tol.parametric() * 1e3))?;
1131        let mut outers: Vec<Outer> = Vec::new();
1132        let mut holes: Vec<(Vec<Shape>, Vec<Point2>)> = Vec::new();
1133        for (edges, outline) in loops {
1134            if signed_area(&outline) * outer_sign > 0.0 {
1135                outers.push((edges, outline, Vec::new()));
1136            } else {
1137                holes.push((edges, outline));
1138            }
1139        }
1140        for (edges, outline) in holes {
1141            let probe = outline[outline.len() / 2];
1142            let Some(host) = outers
1143                .iter_mut()
1144                .filter(|o| inside(std::slice::from_ref(&o.1), probe))
1145                .min_by(|a, b| signed_area(&a.1).abs().total_cmp(&signed_area(&b.1).abs()))
1146            else {
1147                ogeom_bail!(Construction, "a hole of the cut face lies in no piece");
1148            };
1149            host.2.push(edges);
1150        }
1151        for (edges, _, hole_edges) in outers {
1152            let mut wires = vec![model.add_wire(&edges)?];
1153            for h in hole_edges {
1154                wires.push(model.add_wire(&h)?);
1155            }
1156            let mut fresh = data.clone();
1157            fresh.triangulation = None;
1158            fresh.natural_restriction = false;
1159            faces.push(model.add_face(fresh, &wires)?);
1160        }
1161    }
1162    if faces.len() < 2 {
1163        return Ok(None);
1164    }
1165    reshape.split(&face_fwd, faces);
1166    Ok(Some(reshape))
1167}
1168
1169/// Record a crossing a fraction `s` along `occurrence`.
1170fn record(
1171    occurrence: &Occurrence,
1172    s: f64,
1173    line: IsoLine,
1174    cuts: &mut HashMap<TShapeId, Vec<f64>>,
1175    meetings: &mut Vec<(f64, Meeting)>,
1176    tol: Tolerances,
1177) -> OgeomResult<()> {
1178    let at = occurrence.at(s, tol)?;
1179    // As a fraction of the forward edge, rounded so two traversals of one
1180    // seam name the same point.
1181    let forward_s = if occurrence.reversed() { 1.0 - s } else { s };
1182    let forward_s = (forward_s * 1e12).round() / 1e12;
1183    let node = occurrence.edge.node();
1184    cuts.entry(node).or_default().push(forward_s);
1185    meetings.push((line.free(at), Meeting::Inside(node, forward_s.to_bits())));
1186    Ok(())
1187}
1188
1189/// One directed piece of a ring being assembled.
1190#[derive(Debug, Clone)]
1191struct Item {
1192    edge: Shape,
1193    from: TShapeId,
1194    to: TShapeId,
1195    polyline: Vec<Point2>,
1196}
1197
1198/// Chain directed pieces end to start into closed rings: at a vertex, the
1199/// piece whose chart start is nearest where the ring stands.
1200fn chain(mut items: Vec<Item>, snap: f64) -> OgeomResult<Vec<(Vec<Shape>, Vec<Point2>)>> {
1201    let mut loops = Vec::new();
1202    while let Some(first) = items.pop() {
1203        let start_node = first.from;
1204        let start_at = first.polyline[0];
1205        let mut here = *first.polyline.last().unwrap_or(&start_at);
1206        let mut at_node = first.to;
1207        let mut edges = vec![first.edge];
1208        let mut outline = first.polyline;
1209        loop {
1210            if at_node == start_node && here.distance(start_at) <= snap {
1211                break;
1212            }
1213            let next = items
1214                .iter()
1215                .enumerate()
1216                .filter(|(_, it)| it.from == at_node)
1217                .min_by(|a, b| {
1218                    a.1.polyline[0]
1219                        .distance(here)
1220                        .total_cmp(&b.1.polyline[0].distance(here))
1221                })
1222                .map(|(i, _)| i);
1223            let Some(i) = next else {
1224                ogeom_bail!(Construction, "a piece of the cut face does not close");
1225            };
1226            let it = items.swap_remove(i);
1227            here = *it.polyline.last().unwrap_or(&here);
1228            at_node = it.to;
1229            edges.push(it.edge);
1230            outline.extend(it.polyline);
1231        }
1232        loops.push((edges, outline));
1233    }
1234    Ok(loops)
1235}
1236
1237/// Split `edge` (forward) at fractions of its range, in order: the pieces
1238/// in order, and the new vertex at each fraction. Every description the
1239/// edge has is cut at the same fraction of its own range.
1240fn split_edge(
1241    model: &mut Model,
1242    edge: &Shape,
1243    fractions: &[f64],
1244    tol: Tolerances,
1245) -> OgeomResult<(Vec<Shape>, Vec<Shape>)> {
1246    let data = edge_data(model, edge)?;
1247    let Some((start, end)) = edge_vertices(model, edge)? else {
1248        ogeom_bail!(Construction, "an edge with no vertices cannot be cut");
1249    };
1250    let mut vertices = Vec::with_capacity(fractions.len());
1251    for &s in fractions {
1252        let vertex = match data.curve3d() {
1253            Some(EdgeRepr::Curve3d { curve, range, .. }) if !data.degenerate => {
1254                let Some(curve) = model.geometry().curve(*curve) else {
1255                    ogeom_bail!(Dangling, "curve is not in this model");
1256                };
1257                let p = curve.point_at(range.0 + (range.1 - range.0) * s, tol)?;
1258                model.add_vertex(VertexData::with_tolerance(p, data.tolerance.get())?)
1259            }
1260            // A degenerate edge is one point, however it is cut.
1261            _ => start.clone(),
1262        };
1263        vertices.push(vertex);
1264    }
1265    let mut bounds = vec![0.0];
1266    bounds.extend(fractions);
1267    bounds.push(1.0);
1268    let mut ends = vec![start];
1269    ends.extend(vertices.iter().cloned());
1270    ends.push(end);
1271    let mut pieces = Vec::with_capacity(bounds.len() - 1);
1272    for k in 0..bounds.len() - 1 {
1273        let (a, b) = (bounds[k], bounds[k + 1]);
1274        let sub = |range: (f64, f64)| {
1275            (
1276                range.0 + (range.1 - range.0) * a,
1277                range.0 + (range.1 - range.0) * b,
1278            )
1279        };
1280        let mut piece = data.clone();
1281        piece.representations.retain(|r| {
1282            matches!(
1283                r,
1284                EdgeRepr::Curve3d { .. } | EdgeRepr::PCurve { .. } | EdgeRepr::Seam { .. }
1285            )
1286        });
1287        for repr in &mut piece.representations {
1288            match repr {
1289                EdgeRepr::Curve3d { range, .. }
1290                | EdgeRepr::PCurve { range, .. }
1291                | EdgeRepr::Seam { range, .. } => *range = sub(*range),
1292                _ => {}
1293            }
1294        }
1295        pieces.push(model.add_edge(piece, &[ends[k].clone(), ends[k + 1].clone()])?);
1296    }
1297    Ok((pieces, vertices))
1298}
1299
1300/// The surface's iso-curve along `line`, and the map from the line's free
1301/// parameter `w` to the curve's own: `t = scale * w + offset`.
1302fn iso_curve(
1303    surface: &SurfaceGeometry,
1304    line: IsoLine,
1305    probes: &[f64],
1306    span: (f64, f64),
1307    tol: Tolerances,
1308) -> OgeomResult<Option<(Curve, f64, f64)>> {
1309    let c = line.at();
1310    let circle = |centre: Point, z: Direction, x: Point, radius: f64| -> OgeomResult<Curve> {
1311        let x = Direction::new(x - centre, tol)?;
1312        let frame = Frame::new(centre, z, x, tol)?;
1313        Ok(CircleCurve::new(Circle::new(frame, radius, tol)?).into())
1314    };
1315    let straight = |at: Point, along: ogeom_math::Vector| -> OgeomResult<(Curve, f64)> {
1316        let length = along.magnitude();
1317        let direction = Direction::new(along, tol)?;
1318        Ok((
1319            LineCurve::new(Axis {
1320                location: at,
1321                direction,
1322            })
1323            .into(),
1324            length,
1325        ))
1326    };
1327    let found: (Curve, f64, f64) = match (surface, line) {
1328        (SurfaceGeometry::BSpline(b), IsoLine::U(_)) => {
1329            (Curve::BSpline(b.iso_u_curve(c, tol)?), 1.0, 0.0)
1330        }
1331        (SurfaceGeometry::BSpline(b), IsoLine::V(_)) => {
1332            (Curve::BSpline(b.iso_v_curve(c, tol)?), 1.0, 0.0)
1333        }
1334        (
1335            SurfaceGeometry::Plane(_) | SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_),
1336            IsoLine::U(_),
1337        )
1338        | (SurfaceGeometry::Plane(_) | SurfaceGeometry::Extrusion(_), _) => {
1339            // Straight in the free parameter: through the point at w = 0,
1340            // along the rate the surface moves with w.
1341            let at = surface.point_at(line.point(0.0).x, line.point(0.0).y, tol)?;
1342            let next = surface.point_at(line.point(1.0).x, line.point(1.0).y, tol)?;
1343            match (surface, line) {
1344                (SurfaceGeometry::Extrusion(e), IsoLine::V(_)) => {
1345                    let shift = Transform::translation(e.direction().vector() * c);
1346                    (e.curve().transformed(&shift, tol)?, 1.0, 0.0)
1347                }
1348                _ => {
1349                    let (curve, speed) = straight(at, next - at)?;
1350                    (curve, speed, 0.0)
1351                }
1352            }
1353        }
1354        (
1355            SurfaceGeometry::Cylinder(_)
1356            | SurfaceGeometry::Cone(_)
1357            | SurfaceGeometry::Sphere(_)
1358            | SurfaceGeometry::Torus(_)
1359            | SurfaceGeometry::Revolution(_),
1360            IsoLine::V(_),
1361        ) => {
1362            // A parallel: the circle the surface's `u = 0` point turns on.
1363            let axis = match surface {
1364                SurfaceGeometry::Cylinder(s) => frame_axis(s.cylinder().frame()),
1365                SurfaceGeometry::Cone(s) => frame_axis(s.cone().frame()),
1366                SurfaceGeometry::Sphere(s) => frame_axis(s.sphere().frame()),
1367                SurfaceGeometry::Torus(s) => frame_axis(s.torus().frame()),
1368                SurfaceGeometry::Revolution(s) => s.axis(),
1369                _ => return Ok(None),
1370            };
1371            let start = surface.point_at(0.0, c, tol)?;
1372            let centre = axis.project(start);
1373            let radius = start.distance(centre);
1374            if radius <= tol.confusion() {
1375                return Ok(None);
1376            }
1377            (circle(centre, axis.direction, start, radius)?, 1.0, 0.0)
1378        }
1379        (SurfaceGeometry::Sphere(_) | SurfaceGeometry::Torus(_), IsoLine::U(_)) => {
1380            // A meridian or tube circle, its angle the surface's `v`.
1381            let at0 = surface.point_at(c, 0.0, tol)?;
1382            let quarter = surface.point_at(c, core::f64::consts::FRAC_PI_2, tol)?;
1383            let opposite = surface.point_at(c, -core::f64::consts::FRAC_PI_2, tol)?;
1384            let centre = Point::from_vector((quarter.to_vector() + opposite.to_vector()) * 0.5);
1385            let radius = at0.distance(centre);
1386            let z = Direction::new((at0 - centre).cross(quarter - centre), tol)?;
1387            (circle(centre, z, at0, radius)?, 1.0, 0.0)
1388        }
1389        (SurfaceGeometry::Revolution(r), IsoLine::U(_)) => {
1390            let turn = Transform::rotation(r.axis(), c);
1391            (r.curve().transformed(&turn, tol)?, 1.0, 0.0)
1392        }
1393        (SurfaceGeometry::Trimmed(t), _) => return iso_curve(t.basis(), line, probes, span, tol),
1394        // No closed form (an offset of a spline): the iso-curve fitted
1395        // through its own points at its own parameters, same-parameter
1396        // with the straight chart line it runs along.
1397        _ => {
1398            const N: usize = 128;
1399            let (lo, hi) = span;
1400            if hi <= lo || !hi.is_finite() || !lo.is_finite() {
1401                return Ok(None);
1402            }
1403            #[allow(clippy::cast_precision_loss)]
1404            let params: Vec<f64> = (0..=N)
1405                .map(|i| lo + (hi - lo) * i as f64 / N as f64)
1406                .collect();
1407            let points: Vec<Point> = params
1408                .iter()
1409                .map(|w| {
1410                    let on = line.point(*w);
1411                    surface.point_at(on.x, on.y, tol)
1412                })
1413                .collect::<OgeomResult<_>>()?;
1414            let fitted = ogeom_geom::fit::fit_points_at(&params, &points, 3, tol.confusion(), tol)?;
1415            if !fitted.met {
1416                return Ok(None);
1417            }
1418            (Curve::BSpline(fitted.curve), 1.0, 0.0)
1419        }
1420    };
1421    // The closed forms above assume the surfaces' own conventions; hold
1422    // them to it.
1423    let (curve, scale, offset) = &found;
1424    for &w in probes {
1425        let on = line.point(w);
1426        let expected = surface.point_at(on.x, on.y, tol)?;
1427        let got = curve.point_at(scale * w + offset, tol)?;
1428        if expected.distance(got) > tol.confusion() {
1429            return Ok(None);
1430        }
1431    }
1432    Ok(Some(found))
1433}
1434
1435fn frame_axis(frame: Frame) -> Axis {
1436    Axis {
1437        location: frame.origin(),
1438        direction: frame.z(),
1439    }
1440}
1441
1442// --- helpers -------------------------------------------------------------------
1443
1444fn forward(shape: &Shape) -> Shape {
1445    if shape.orientation() == Orientation::Reversed {
1446        shape.reversed()
1447    } else {
1448        shape.clone()
1449    }
1450}
1451
1452fn edge_data(model: &Model, edge: &Shape) -> OgeomResult<EdgeData> {
1453    match model.node(edge).map(ogeom_topo::TShape::data) {
1454        Some(NodeData::Edge(data)) => Ok((**data).clone()),
1455        Some(_) => ogeom_bail!(Construction, "expected an edge"),
1456        None => ogeom_bail!(Dangling, "edge is not in this model"),
1457    }
1458}
1459
1460fn face_data(model: &Model, face: &Shape) -> OgeomResult<ogeom_topo::FaceData> {
1461    match model.node(face).map(ogeom_topo::TShape::data) {
1462        Some(NodeData::Face(data)) => Ok((**data).clone()),
1463        Some(_) => ogeom_bail!(Construction, "expected a face"),
1464        None => ogeom_bail!(Dangling, "face is not in this model"),
1465    }
1466}
1467
1468fn vertex_point(model: &Model, vertex: &Shape) -> OgeomResult<Point> {
1469    let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
1470        ogeom_bail!(Construction, "a vertex holds no point");
1471    };
1472    Ok(data.point)
1473}
1474
1475fn signed_area(ring: &[Point2]) -> f64 {
1476    let n = ring.len();
1477    (0..n)
1478        .map(|i| {
1479            let (a, b) = (ring[i], ring[(i + 1) % n]);
1480            a.x * b.y - b.x * a.y
1481        })
1482        .sum::<f64>()
1483        * 0.5
1484}
1485
1486/// Even-odd containment against every ring.
1487fn inside(rings: &[Vec<Point2>], p: Point2) -> bool {
1488    let mut odd = false;
1489    for ring in rings {
1490        let n = ring.len();
1491        for i in 0..n {
1492            let (a, b) = (ring[i], ring[(i + 1) % n]);
1493            if (a.y > p.y) != (b.y > p.y) {
1494                let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
1495                if x > p.x {
1496                    odd = !odd;
1497                }
1498            }
1499        }
1500    }
1501    odd
1502}