Skip to main content

axiolid_mesh_compile/
station.rs

1//! Stations along a graph's curves, resolved (#241, ADR 0082).
2//!
3//! A [`CurveStation`] node, an [`OffsetByStations`] curve, a
4//! [`StationedSpine`] solid and a [`SectionedSurface`] all name positions
5//! by a distance along a basis curve. This module resolves them through
6//! `axiolid_reference::station`, whose module documentation states the
7//! distance and frame conventions and the accuracy every resolved point
8//! carries (the arc-length inverse's `ARC_LENGTH_TOLERANCE` where the
9//! measure is numerical).
10//!
11//! The basis is an atomic 2D or 3D curve node, a curve placed at a station
12//! (#264), or a curve relation a distance runs along (#285, #289): a
13//! composite, a trim, a surface curve whose 3D curve governs, an offset or
14//! an offset curve by stations, nested and placed in any combination,
15//! measured as one composite (see [Composite bases](#composite-bases-285)
16//! and [Offset bases](#offset-bases-289)). An instance and any other
17//! relation (a p-curve) are refused by name.
18//!
19//! # Meshing between stations
20//!
21//! Everything between two stations is interpolated linearly in distance:
22//! the offsets, and each section point with its counterpart. The meshed
23//! sections stand at the stations and at distances bisected between them
24//! until every placed point at an interval's midpoint is within the chord
25//! budget of the midpoint of its chord, and (#247) until each cell's
26//! certified bound fits the budget. The `bound` module states the exact
27//! surface and the bound: a sectioned spine or surface reports
28//! [`DeviationBound::Certified`] wherever the frame law along its basis has
29//! certified derivative bounds -- since #316 also where it reads its frame
30//! by quadrature, on an ellipse, a B-spline or an offset beside a curve in
31//! closed form, and since #361 on an intrinsic plan (a clothoid, or lines,
32//! arcs and transitions in one piecewise law), alone or under an elevation
33//! -- and, since #396, a banked curve over it with the roll bounded from the
34//! cant law -- and [`DeviationBound::Unbounded`] by name elsewhere (a frame on a
35//! chain, on a banked curve over one or on an intrinsic space
36//! curve with torsion or tilted, an offset beside an intrinsic curve or a
37//! chain, different curved profiles that do not pair). A cell beside a
38//! mitre is bounded (#335) against the exact mitred solid, see
39//! [seams](#seams-263).
40//!
41//! Different curved profiles that pair segment for segment (#361) -- lines,
42//! circular and elliptic arcs, matched by ring and segment index after
43//! orientation -- are flattened together
44//! ([`axiolid_construct::profile::paired_profile_rings`]): each segment is
45//! cut into one common number of equal steps of its own parameter, so a
46//! vertex of one section is matched to the vertex at the same fraction of
47//! the other and the flattening bound holds for every interpolated section,
48//! not only the stations. That bound is the profile's share, and the fold
49//! margin. A polyline or B-spline segment, another segment family or a
50//! different segment structure does not pair and stays unbounded by name;
51//! the exact compiler refuses a curved section that changes either way.
52//!
53//! A spine whose section planes fold is no solid (#361), and a sectioned
54//! surface or open-section run alike no sheet (#407): where a section
55//! vertex lies on the inner side of a bend at or past its centre of
56//! curvature, or recedes along the tangent faster than the basis advances,
57//! the planes stop advancing and the sections cross. The spine is refused as
58//! invalid input, by this compiler and the exact one alike, wherever the
59//! frame law has a closed-form bend (unturned sections), a touch within
60//! rounding included; for a turned section or a frame read by quadrature the
61//! `bound` module's conservative bound never refuses and reports
62//! [`DeviationBound::Unbounded`] by name when it cannot exclude a fold. The
63//! check is local: a run that meets itself far away is not detected (#407);
64//! a turned section or a frame read by quadrature is never refused (#406).
65//!
66//! The `exact` module builds the runs with a closed form exactly (#247):
67//! a constant section along a line or a straight polyline (an extrusion)
68//! or along a horizontal circle (a revolution), and a linear taper of
69//! straight-edged sections along a straight basis (a planar polyhedron).
70//!
71//! # Orientation and tags (#246)
72//!
73//! An [`OrientedCurveStation`] and the sections of a
74//! [`SectionsAtStations`] spine or an [`OpenSectionsAtStations`] surface may
75//! carry an explicit orientation, which turns the frame a profile is placed
76//! in (and a resolved station presents) while the offsets stay in the base
77//! frame; between two sections the unit axis and reference direction are
78//! interpolated linearly in the base frame and orthonormalised again.
79//! Tagged sections are matched by tag: an open section's polyline is
80//! reversed when its tags run backwards, and a closed section's rings are
81//! re-ordered and re-started so that every ring lines up with the first
82//! section's, which needs a polygonal contour. The rules are
83//! `axiolid_model::station`'s; a mismatch is refused by name.
84//!
85//! # Seams (#263)
86//!
87//! A station on a seam of its basis curve (`axiolid_reference::station`'s
88//! module documentation says when it is on one) reads the piece its
89//! [`SeamSide`] names: an [`OrientedCurveStation`]'s own side, the
90//! outgoing piece for a plain [`CurveStation`]. A run of sections or
91//! offsets reads the outgoing piece at its first station and the incoming
92//! one at its last, the pieces it lies on. Where it crosses a seam whose
93//! two tangents differ -- between two stations, or at a station standing
94//! on the seam -- it gets a section in the seam's [`Mitre`] plane, the
95//! bisector of the two tangents, each side's placement projected along its
96//! own tangent onto the plane; the sampling meets that section exactly and
97//! refines on either side of it as before. A near reversal is refused by
98//! name. A seam whose tangents agree (a chain join, a jump in a banked
99//! curve's roll alone) is sampled as before.
100//!
101//! The deviation reported across a mitre (#335) is measured against the
102//! exact mitred solid: each side's surface extruded past its end along its
103//! end tangent and cut by the mitre plane, moved along its normal by the
104//! longitudinal offset, plus the planar faces between the two cuts. The mesh
105//! keeps its one mitre row, the midpoint of the projections; the `bound`
106//! module's `mitre` states the bound per cell. A cell that does not cross the
107//! plane transversally, or lies between two mitres on a curved piece or with
108//! a changing section, is unbounded by name and refined; a gap between the
109//! mitre row and a side's cut that no refinement removes is reported as it is.
110//!
111//! An [`OrientedCurveStation`] may carry a seam-snapping window (#294),
112//! a distance in its basis's measure: within it of exactly one seam whose
113//! frame may jump, the station is read ON that seam from its side, its
114//! point the seam's (`axiolid_reference::station::window` states the
115//! rule); a window holding two seams, or with one whose certified position
116//! straddles its edge, is refused by name. Every resolution of an oriented
117//! station applies it: [`resolve`], [`placement`] and so an
118//! [`InstanceAtStation`] wherever it is compiled or used as a basis. A
119//! numerical joint (after an offset, an ellipse or a B-spline piece) is
120//! located by its certified interval
121//! (`axiolid_reference::station::CompositeBasis::joint_intervals`).
122//!
123//! The stations of a run carry a window and a side too (#301): the
124//! sections of a [`SectionsAtStations`] spine or an
125//! [`OpenSectionsAtStations`] surface, and the stations of an
126//! [`OffsetsAtStations`] curve. Each is snapped by the same rule before the
127//! run is read, and the run then stands on the snapped distances; snapped
128//! distances that no longer increase strictly are refused by name. An
129//! interior station snapped onto a seam whose tangents differ is the
130//! mitre's knot, so its section is the mitre section whatever side it
131//! names; the run's first station reads the outgoing piece and its last
132//! the incoming one, as before; an interior station on a seam whose
133//! tangents agree is placed in the frame of the side it names. An offset
134//! by stations used as a basis breaks its law at the snapped distances.
135//!
136//! A station of an [`OffsetsAtStations`] curve may stand at its basis's
137//! start or end without stating the distance (#303,
138//! `axiolid_model::RunStation::at_basis_end`). It is resolved before the
139//! run is snapped: the start to `0`, the end to the basis's length by the
140//! kernel's own measure, the length [`curve_path`] states for the basis as
141//! `axiolid_reference::station::CompositeBasis` measures it, bitwise (a
142//! line's parameter domain `|d|`, as #298 resolves an open path end). The
143//! resolved distances must increase strictly, so a stated station at or
144//! past the basis's end is refused by name; the windows then snap them as
145//! above. Every reading of the offset resolves alike: swept as a
146//! directrix, and flattened as a basis or by [`curve_path`].
147//!
148//! [`seams`] reads where a graph curve's seams are without evaluating it:
149//! an atomic curve's from its stored data, a composite basis's joints at
150//! the running sum of its pieces' lengths with each piece's own seams
151//! inside it (#285).
152//!
153//! # Placing at a station (#264)
154//!
155//! An [`InstanceAtStation`] reuses a node in the frame of an oriented
156//! station: [`placement`] resolves that station exactly as [`resolve`]
157//! does (its frame, orientation and seam side) and returns the rigid
158//! motion taking the source's local `x`, `y`, `z` onto the oriented
159//! tangent, left lateral and up, its origin onto the station's point
160//! (`axiolid_model::station` names the mapping). The frame is exact only
161//! on a line ([`ResolvedPlacement::exact`]); on any other basis it is the
162//! exact curve's at a distance read by an estimate, and
163//! [`placement_accuracy`] states how far the placement lies from the exact
164//! one (#405): [`PlacementAccuracy::Certified`] on a circle, a polyline, an
165//! elevated or banked curve over a line or a circle, an intrinsic plan
166//! alone or under a closed-form profile and composites of them, where a
167//! mesh placed in it reports the frame's error as a certified
168//! `StationPlacement` contribution that grows every bound of the instance;
169//! [`PlacementAccuracy::Unbounded`] by name elsewhere, where it reports its
170//! placement [`DeviationBound::Unbounded`]. The `accuracy` module states
171//! the bound.
172//!
173//! A station along a placed curve is its source's station carried by the
174//! placement: a rigid motion keeps arc length, and one that keeps `+Z`
175//! keeps plan distance and carries the source's section frame
176//! (reference-up, banked or 2D) onto the placed curve's own. Where the
177//! placement tilts `+Z` (#285), an arc-length-measured source's station
178//! keeps its carried point and tangent and takes the placed curve's own
179//! reference-up frame against `+Z`, since the carried frame is not it; an
180//! elevated or banked source so placed is refused by name, its plan
181//! distance not being the placed curve's
182//! (`axiolid_reference::station::SectionFrame::carried`). The source may
183//! be any curve a station can be measured along, a relation included.
184//!
185//! # Composite bases (#285)
186//!
187//! A curve relation is flattened into the spans of atomic curves it runs
188//! along (`axiolid_reference::station::StationPiece`), read as a
189//! composite directrix reads it: segments in order, one whose sense
190//! disagrees reversed, an untrimmed line its domain `[0, 1]`, a trim
191//! between the parameters its selectors name (a closed conic's possibly
192//! across its parameter seam), a trim of a relation between parameter
193//! selectors read as its arc length, a placed curve carried by its
194//! placement. The spans are measured as one
195//! `axiolid_reference::station::CompositeBasis`, whose module
196//! documentation states the rules:
197//!
198//! - the distance runs end to end through the pieces, each in its own
199//!   convention, all of which must agree: plan distance when every piece
200//!   is an elevated or banked curve, arc length when none is (so a
201//!   composite of segments placed at stations of an elevated curve is
202//!   measured in its own segments' arc length); mixed pieces are refused
203//!   by name;
204//! - consecutive pieces must meet within
205//!   `axiolid_reference::station::JOINT_TOLERANCE` (relative); a gap and
206//!   a piece whose undeclared sense runs it backwards are refused by name;
207//! - every joint is a seam under the #263 rule: a station on it reads the
208//!   piece its [`SeamSide`] names, the incoming one at its end or the
209//!   outgoing one at its start; a run crossing a joint whose tangents
210//!   differ is mitred there like any seam;
211//! - a frame on a composite is exact only where every piece up to and
212//!   including the one read is a line placed, if at all, exactly
213//!   ([`ResolvedPlacement::exact`]).
214//!
215//! # Curve paths (#290)
216//!
217//! [`curve_path`] hands out the flattening itself: the relation as an
218//! owned `axiolid_curve::CurvePath`, the neutral pieces a consumer reads through
219//! the curve-evaluation contract's `path_point_at_on`, `path_tangent_at_on`
220//! and `path_frame_at_on`. The reference provider measures it with the same
221//! `CompositeBasis` a station here is resolved by, so a placement framed
222//! through the contract agrees with the station lowered as geometry.
223//!
224//! # Offset bases (#289)
225//!
226//! A constant offset (2D: along the left normal; 3D: along `V x T`) and an
227//! offset curve by stations are flattened into offset pieces
228//! (`axiolid_reference::station::offset_pieces`): the basis split at every
229//! seam of its pieces and, for an offset by stations, at every station, so
230//! each seam of the basis and each break of the distance law is a joint of
231//! the offset. A station along it is measured in the offset's OWN length
232//! from its start (its own plan length beside an elevated or banked curve)
233//! and framed by its own section frame; the
234//! `axiolid_reference::station::offset` module documentation states the
235//! closed forms (a line beside a line, exact; a circle beside a circle),
236//! the numerical reading of every other offset (never exact, so a placement
237//! on it is unbounded) and the refusals: a collapse, a cusp, a planar
238//! self-crossing, an offset across a corner of its basis where its two
239//! sides do not meet, an offset of an offset. A trim of an offset relation
240//! is refused by name: an offset takes its basis's parameter, not its own
241//! length.
242//!
243//! [`InstanceAtStation`]: axiolid_model::InstanceAtStation
244//! [`OffsetByStations`]: axiolid_model::CurveRelation::OffsetByStations
245//! [`OffsetsAtStations`]: axiolid_model::CurveRelation::OffsetsAtStations
246//! [`SectionsAtStations`]: axiolid_model::SolidOperation::SectionsAtStations
247//! [`OpenSectionsAtStations`]: axiolid_model::SurfaceRelation::OpenSectionsAtStations
248//! [`StationedSpine`]: axiolid_model::SolidOperation::StationedSpine
249//! [`SectionedSurface`]: axiolid_model::SurfaceRelation::SectionedSurface
250//! [`DeviationBound::Unbounded`]: crate::DeviationBound::Unbounded
251
252use axiolid_construct::loft::{loft_tapered, Station as LoftStation};
253use axiolid_construct::profile::{
254    paired_profile_rings, profile_deviation, profile_rings, ProfileDeviation, Rings,
255};
256use axiolid_contracts::{ExecutionOptions, GeomError, GeomResult, Operation};
257use axiolid_core::{Frame3, Point2, Point3, Scalar, Tolerance, Transform3};
258use axiolid_curve::Curve2;
259use axiolid_mesh::TriMesh;
260use axiolid_model::{
261    BasisEnd, CurveRelation, CurveStation, GeometryGraph, GeometryNode, NodeId,
262    OrientedCurveStation, RunStation, SeamSide, SectionAtStation, StationFrame, StationOffsets,
263    StationOrientation,
264};
265use axiolid_profile::{Contour, Profile};
266use axiolid_reference::arc_parameter::ARC_LENGTH_TOLERANCE;
267use axiolid_reference::station::{Mitre, SectionFrame, StationSeam};
268
269use crate::DeviationBound;
270
271mod accuracy;
272mod basis;
273mod bound;
274mod exact;
275mod trim;
276
277pub(crate) use accuracy::UNCERTIFIED_STATION_FRAME;
278pub use accuracy::{placement_accuracy, PlacementAccuracy, PlacementBound};
279pub use basis::curve_path;
280pub(crate) use basis::{relation_pieces, trim_parameters, Basis};
281pub(crate) use bound::CERTIFICATION_SHARE;
282pub(crate) use exact::stationed_spine_exact;
283pub(crate) use trim::{composite_basis, trimmed_span};
284
285/// Most sections one interval between two stations is bisected into.
286const MAX_DEPTH: u32 = 12;
287
288/// Most sections one stationed sweep or offset curve may mesh.
289const MAX_SECTIONS: usize = 1 << 16;
290
291/// A station resolved to a point and a frame.
292#[non_exhaustive]
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct ResolvedStation {
295    /// The station's point: the curve point moved by the offsets.
296    pub point: Point3,
297    /// The frame at the station, at `point`: `x` the tangent, `y` up,
298    /// `z` to the right (`-lateral`), the curve-evaluation provider's
299    /// layout.
300    pub frame: Frame3,
301    /// The basis curve's section frame at the distance, before the
302    /// offsets, in the requested [`StationFrame`]; on a seam, the frame of
303    /// the piece the station's [`SeamSide`] names.
304    pub section: SectionFrame,
305}
306
307/// Resolve a [`CurveStation`] or an [`OrientedCurveStation`] node to its
308/// point and frame.
309///
310/// See the [module documentation](self) and
311/// `axiolid_reference::station` for the conventions and the accuracy. An
312/// oriented station's frame is turned by its orientation; its point, and
313/// [`ResolvedStation::section`], are not. On a seam of the basis curve an
314/// oriented station reads the piece its `seam` names, a plain one the
315/// outgoing piece (#263); within its seam-snapping window of one seam, an
316/// oriented station is read on that seam (#294).
317///
318/// # Errors
319///
320/// A node that is not a [`GeometryNode::CurveStation`] or a
321/// [`GeometryNode::OrientedCurveStation`], a basis no station can be
322/// measured along (see the [module documentation](self): an instance, a
323/// p-curve, a composite with a gap, an undeclared reversed piece or pieces
324/// measured differently, a tilted plan-measured placement, a degenerate
325/// offset), a distance beyond the basis curve's length, a degenerate
326/// orientation, a seam-snapping window holding two seams or with a seam on
327/// its edge, a seam whose distance cannot be certified, and every refusal
328/// of the curve evaluators, by name.
329pub fn resolve(graph: &GeometryGraph, id: NodeId) -> GeomResult<ResolvedStation> {
330    let station = match graph.get(id) {
331        Some(GeometryNode::CurveStation(station)) => OrientedCurveStation::from(*station),
332        Some(GeometryNode::OrientedCurveStation(station)) => *station,
333        _ => {
334            return Err(GeomError::InvalidInput(format!(
335                "node {id:?} is not a curve station"
336            )))
337        }
338    };
339    resolve_oriented(graph, &station, 0).map(|(resolved, _, _)| resolved)
340}
341
342/// An oriented station resolved, with what it was resolved from.
343struct Parts<'g> {
344    /// The basis the station is measured along.
345    basis: Basis<'g>,
346    /// The distance the station is read at, snapped onto its window's seam.
347    distance: Scalar,
348    resolved: ResolvedStation,
349    /// The frame at the station turned by its orientation.
350    turned: SectionFrame,
351    /// Whether that frame is exact (see [`ResolvedPlacement::exact`]).
352    exact: bool,
353}
354
355/// An oriented station resolved: the station, its turned frame, and
356/// whether that frame is exact (see [`ResolvedPlacement::exact`]).
357fn resolve_oriented(
358    graph: &GeometryGraph,
359    station: &OrientedCurveStation,
360    depth: usize,
361) -> GeomResult<(ResolvedStation, SectionFrame, bool)> {
362    let parts = resolve_parts(graph, station, depth)?;
363    Ok((parts.resolved, parts.turned, parts.exact))
364}
365
366/// [`resolve_oriented`] with the basis and the distance the frame was read
367/// at, which [`placement_accuracy`] reads the same frame's error at.
368fn resolve_parts<'g>(
369    graph: &'g GeometryGraph,
370    station: &OrientedCurveStation,
371    depth: usize,
372) -> GeomResult<Parts<'g>> {
373    let OrientedCurveStation {
374        station: CurveStation {
375            basis,
376            station,
377            frame,
378        },
379        orientation,
380        seam,
381        seam_window,
382        ..
383    } = station;
384    let basis = Basis::of_depth(graph, *basis, depth)?;
385    // Within its window of one seam, the station is read on that seam
386    // (#294); with no window, where it stands.
387    let distance = basis.snapped_distance(station.distance, *seam_window)?;
388    let section = basis.section_on(distance, *frame, *seam)?;
389    let point = place(&section, &station.offsets, Point2::ZERO);
390    let turned = oriented(&section, orientation, orientation, 0.0)?;
391    let mut placed = turned.frame();
392    placed.origin = point;
393    let exact = basis.frame_is_exact(distance, *seam);
394    Ok(Parts {
395        basis,
396        distance,
397        resolved: ResolvedStation {
398            point,
399            frame: placed,
400            section,
401        },
402        turned,
403        exact,
404    })
405}
406
407/// A node placed at a station (#264), resolved.
408#[non_exhaustive]
409#[derive(Debug, Clone, Copy, PartialEq)]
410pub struct ResolvedPlacement {
411    /// The rigid motion from the source's local coordinates: local `x`,
412    /// `y`, `z` onto the station's oriented tangent, left lateral and up,
413    /// the local origin onto [`ResolvedStation::point`].
414    pub transform: Transform3,
415    /// The station the node is placed at.
416    pub station: ResolvedStation,
417    /// Whether the frame is exact, rounding aside: the basis is a line
418    /// (`axiolid_reference::station::station_frame_is_exact2` and
419    /// `station_frame_is_exact3`), itself placed, if at all, in exact
420    /// frames; on a composite basis (#285), every piece up to and
421    /// including the one the station is read on is such a line. Otherwise
422    /// the frame is the exact curve's at a distance read by an estimate
423    /// (the arc-length inverse, quadrature), and nothing placed in it is
424    /// reported exact; [`placement_accuracy`] bounds its error (#405).
425    pub exact: bool,
426}
427
428/// Resolve an [`InstanceAtStation`] node's placement (#264).
429///
430/// The frame is the one [`resolve`] gives the node's
431/// [`OrientedCurveStation`], seam side included; the transform maps the
432/// source's local `x`, `y`, `z` onto its oriented tangent, left lateral
433/// and up (`axiolid_model::station` names the mapping).
434///
435/// # Errors
436///
437/// A node that is not an [`InstanceAtStation`], and every refusal of
438/// [`resolve`]: a distance beyond the basis curve's length, a zero or
439/// vertical tangent where the frame needs a plan, a degenerate
440/// orientation, a basis no station can be measured along (an instance, a
441/// p-curve, a composite whose pieces do not meet or are measured
442/// differently, a degenerate offset), each by name.
443///
444/// [`InstanceAtStation`]: axiolid_model::InstanceAtStation
445pub fn placement(graph: &GeometryGraph, id: NodeId) -> GeomResult<ResolvedPlacement> {
446    match graph.get(id) {
447        Some(GeometryNode::InstanceAtStation(placed)) => placement_at(graph, &placed.station, 0),
448        _ => Err(GeomError::InvalidInput(format!(
449            "node {id:?} is not an instance at a station"
450        ))),
451    }
452}
453
454fn placement_at(
455    graph: &GeometryGraph,
456    station: &OrientedCurveStation,
457    depth: usize,
458) -> GeomResult<ResolvedPlacement> {
459    let (resolved, turned, exact) = resolve_oriented(graph, station, depth)?;
460    let mut transform = turned.placement();
461    transform.translation = resolved.point;
462    Ok(ResolvedPlacement {
463        transform,
464        station: resolved,
465        exact,
466    })
467}
468
469/// Where a graph curve's seams are, read from its stored data (#263): the
470/// distances in its station measure, with the curve's own parameter.
471///
472/// An atomic 2D or 3D curve reports
473/// `axiolid_reference::station::exact_station_seams2` or
474/// `exact_station_seams3` (every seam exact, or refused), and a curve
475/// placed at a station its source's. A curve relation a station can be
476/// measured along (#285: a composite, a trim, nested and placed, 2D or 3D)
477/// reports `axiolid_reference::station::CompositeBasis::exact_seams`: the
478/// joints of its pieces in the direction of travel, at the running sum of
479/// their lengths in the station measure, and the seams of each piece's
480/// curve inside the piece; the distance is also the parameter reported.
481/// No joint is smooth, since a joint's continuity is declared, not
482/// guaranteed by the data. `options` is not read: the seams are the
483/// curve's, whatever the tolerance.
484///
485/// # Errors
486///
487/// A seam whose distance would need a quadrature (a B-spline's corner
488/// knot, a joint after an ellipse or a B-spline piece), a relation no
489/// station can be measured along, a composite whose pieces do not meet or
490/// are measured differently, an instance, and a node that is not a curve,
491/// each by name.
492pub fn seams(
493    graph: &GeometryGraph,
494    curve: NodeId,
495    _options: &ExecutionOptions,
496) -> GeomResult<Vec<StationSeam>> {
497    match graph.get(curve) {
498        Some(GeometryNode::Instance(_)) => Err(GeomError::UnsupportedInput {
499            backend: crate::BACKEND_ID,
500            operation: Operation::CurveEvaluation,
501            input: "seams of an instanced curve",
502        }),
503        // An atomic curve, a curve placed at a station (its source's seams,
504        // #264) and a curve relation measured as a composite (#285).
505        Some(
506            GeometryNode::Curve2(_)
507            | GeometryNode::Curve3(_)
508            | GeometryNode::InstanceAtStation(_)
509            | GeometryNode::CurveRelation(_),
510        ) => Basis::of(graph, curve)?.exact_seams(),
511        Some(_) => Err(GeomError::InvalidInput(format!(
512            "node {curve:?} is not a curve"
513        ))),
514        None => Err(GeomError::InvalidInput(format!(
515            "curve {curve:?} is outside the graph"
516        ))),
517    }
518}
519
520/// The arc-length tolerance around a distance: what counts as ON a seam.
521fn seam_slack(distance: Scalar) -> Scalar {
522    ARC_LENGTH_TOLERANCE * distance.abs().max(1.0)
523}
524
525/// Why a run's snapped stations cannot be read (#301).
526const SNAPPED_NOT_INCREASING: &str =
527    "station: the run's stations, snapped onto seams within their windows, no longer increase \
528     strictly in distance: two snapped onto one seam, or one snapped past its neighbour";
529
530/// The distances a run's stations are read at (#301): each `(distance,
531/// window)` snapped by `snap`, a basis's own snapping by the #294 rule
532/// (`axiolid_reference::station::window`; a window of `0` keeps the
533/// distance). The snapped distances must still increase strictly.
534fn snapped_run(
535    snap: impl Fn(Scalar, Scalar) -> GeomResult<Scalar>,
536    stations: impl IntoIterator<Item = (Scalar, Scalar)>,
537) -> GeomResult<Vec<Scalar>> {
538    let distances = stations
539        .into_iter()
540        .map(|(distance, window)| snap(distance, window))
541        .collect::<GeomResult<Vec<_>>>()?;
542    if distances.windows(2).any(|pair| pair[1] <= pair[0]) {
543        return Err(GeomError::InvalidInput(SNAPPED_NOT_INCREASING.into()));
544    }
545    Ok(distances)
546}
547
548/// Why a run's stations, the basis's ends resolved, cannot be read (#303).
549const RESOLVED_NOT_INCREASING: &str =
550    "station: the run's stations, its stations at the basis's ends measured by the kernel, do \
551     not increase strictly in distance: a stated station at or past the basis's end";
552
553/// The `(distance, window)` each station of an offset run is snapped from
554/// (#303): a stated station at its distance, one at the basis's start at
555/// `0`, one at its end at `length()`, the basis's length by the kernel's
556/// own measure (read once, and only for a run that has such a station).
557/// The resolved distances must increase strictly.
558fn resolved_run(
559    length: impl FnOnce() -> GeomResult<Scalar>,
560    stations: &[RunStation],
561) -> GeomResult<Vec<(Scalar, Scalar)>> {
562    let end = match stations
563        .iter()
564        .any(|station| station.basis_end == Some(BasisEnd::End))
565    {
566        true => length()?,
567        false => Scalar::NAN,
568    };
569    let resolved = stations
570        .iter()
571        .map(|station| {
572            let distance = match station.basis_end {
573                None => station.station.distance,
574                Some(BasisEnd::Start) => 0.0,
575                Some(BasisEnd::End) => end,
576                Some(_) => {
577                    return Err(GeomError::UnsupportedInput {
578                        backend: crate::BACKEND_ID,
579                        operation: Operation::CurveEvaluation,
580                        input: "a run station at a basis end this compiler does not know",
581                    })
582                }
583            };
584            Ok((distance, station.seam_window))
585        })
586        .collect::<GeomResult<Vec<_>>>()?;
587    if resolved.windows(2).any(|pair| pair[1].0 <= pair[0].0) {
588        return Err(GeomError::InvalidInput(RESOLVED_NOT_INCREASING.into()));
589    }
590    Ok(resolved)
591}
592
593/// The side each station of a run names, at its snapped distance (#301).
594type RunSides = Vec<(Scalar, SeamSide)>;
595
596/// A run of sections' snapped distances (#301, [`snapped_run`]) and the
597/// side each names there.
598fn snapped_sections(
599    basis: &Basis<'_>,
600    sections: &[SectionAtStation],
601) -> GeomResult<(Vec<Scalar>, RunSides)> {
602    let distances = snapped_run(
603        |distance, window| basis.snapped_distance(distance, window),
604        sections
605            .iter()
606            .map(|section| (section.station.distance, section.seam_window)),
607    )?;
608    let sides = distances
609        .iter()
610        .zip(sections)
611        .map(|(at, section)| (*at, section.seam))
612        .collect();
613    Ok((distances, sides))
614}
615
616/// An offset curve by stations, read in its general form (#301): its
617/// basis, its stations (a plain station reads the outgoing piece, with no
618/// window) and its frame.
619fn offset_run(relation: &CurveRelation) -> GeomResult<(NodeId, Vec<RunStation>, StationFrame)> {
620    match relation {
621        CurveRelation::OffsetByStations {
622            basis,
623            stations,
624            frame,
625        } => Ok((
626            *basis,
627            stations.iter().copied().map(RunStation::from).collect(),
628            *frame,
629        )),
630        CurveRelation::OffsetsAtStations {
631            basis,
632            stations,
633            frame,
634        } => Ok((*basis, stations.clone(), *frame)),
635        _ => Err(GeomError::InvalidInput(
636            "not an offset curve by stations".into(),
637        )),
638    }
639}
640
641/// A run of stations along one basis curve, with the seams it meets.
642struct Run<'g> {
643    basis: Basis<'g>,
644    frame: StationFrame,
645    /// Mitred sections at interior distances, each one of the run's
646    /// knots.
647    mitres: Vec<(Scalar, Mitre)>,
648    /// The run's last distance, when it lies on a seam: read from the
649    /// incoming piece there.
650    end: Option<Scalar>,
651    /// The sides the stations after the first name (#301), at their
652    /// snapped distances: read where such a station stands on a seam that
653    /// is not mitred and is not the run's end, which `end` decides.
654    sides: RunSides,
655}
656
657/// How a run reads a distance.
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659enum Reading {
660    /// The section the mesh stands there: mitred on a mitre's knot.
661    Placed,
662    /// On a mitre's knot, the plain section of the piece ending there: the
663    /// interval before it is sampled as that piece alone would be.
664    Incoming,
665    /// On a mitre's knot, the plain section of the piece starting there.
666    Outgoing,
667}
668
669/// A mitre's plane: a point on it and its unit normal.
670type MitrePlane = (Point3, axiolid_core::Vec3);
671
672/// The frames a run places one section in.
673enum RunFrame {
674    /// One frame: `base` locates the origin, `turned` orients the section.
675    /// `before` and `after` are the planes of the nearest mitres ahead of
676    /// and behind the section, which it must not cross.
677    Plain {
678        base: SectionFrame,
679        turned: SectionFrame,
680        before: Option<MitrePlane>,
681        after: Option<MitrePlane>,
682    },
683    /// A mitre, with each side's turned frame.
684    Mitred {
685        mitre: Mitre,
686        incoming: SectionFrame,
687        outgoing: SectionFrame,
688    },
689}
690
691impl RunFrame {
692    /// A section point `local` with `offsets`: placed in the frame, or in
693    /// each side's frame and projected onto the mitre plane. `None` for a
694    /// point of a plain section beyond the plane of a mitre next to it,
695    /// which that mitre would cut.
696    fn place(&self, offsets: &StationOffsets, local: Point2) -> Option<Point3> {
697        match self {
698            Self::Plain {
699                base,
700                turned,
701                before,
702                after,
703            } => {
704                let point = place_turned(base, turned, offsets, local);
705                // Signed height above a mitre plane, along its normal.
706                let height = |(on, normal): &MitrePlane| (point - *on).dot(*normal);
707                let slack = |(on, _): &MitrePlane| seam_slack((point - *on).length());
708                let crosses = before.as_ref().is_some_and(|m| height(m) > slack(m))
709                    || after.as_ref().is_some_and(|m| height(m) < -slack(m));
710                (!crosses).then_some(point)
711            }
712            Self::Mitred {
713                mitre,
714                incoming,
715                outgoing,
716            } => {
717                let across = StationOffsets::new(offsets.lateral, offsets.vertical, 0.0);
718                Some(mitre.place(
719                    place_turned(&mitre.incoming, incoming, &across, local),
720                    place_turned(&mitre.outgoing, outgoing, &across, local),
721                    offsets.longitudinal,
722                ))
723            }
724        }
725    }
726}
727
728/// Why a run cannot place a section: a mitre would cut it.
729fn cut_by_mitre() -> GeomError {
730    GeomError::InvalidInput(
731        "station: a section of the run reaches beyond the mitre plane of a seam the run \
732         crosses, so the mitre would cut it"
733            .into(),
734    )
735}
736
737impl<'g> Run<'g> {
738    /// The run over `knots` (ascending): the seams strictly between its
739    /// first and last knot whose tangents differ get a mitre and a knot of
740    /// their own (an existing knot within tolerance is reused); a seam on
741    /// the last knot is read from its incoming side. `sides` are the sides
742    /// the run's stations name at their distances (#301); the first
743    /// knot's is dropped, since the run reads the outgoing piece there (a
744    /// seam on the last knot is read from its incoming side whatever side
745    /// a station there names). Returns the run and its knots.
746    fn new(
747        basis: Basis<'g>,
748        frame: StationFrame,
749        mut knots: Vec<Scalar>,
750        mut sides: RunSides,
751    ) -> GeomResult<(Self, Vec<Scalar>)> {
752        let (first, last) = (knots[0], knots[knots.len() - 1]);
753        sides.retain(|(at, _)| *at > first);
754        let mut run = Self {
755            basis,
756            frame,
757            mitres: Vec::new(),
758            end: None,
759            sides,
760        };
761        for seam in run.basis.seams()? {
762            let at = seam.distance;
763            if seam.smooth || at <= first + seam_slack(first) || at > last + seam_slack(last) {
764                continue;
765            }
766            if at >= last - seam_slack(last) {
767                run.end = Some(last);
768                continue;
769            }
770            let incoming = run.basis.section_on(at, frame, SeamSide::Incoming)?;
771            let outgoing = run.basis.section_on(at, frame, SeamSide::Outgoing)?;
772            let Some(mitre) = Mitre::between(&incoming, &outgoing)? else {
773                continue;
774            };
775            let knot = match knots.iter().find(|k| (**k - at).abs() <= seam_slack(at)) {
776                Some(k) => *k,
777                None => {
778                    let index = knots.partition_point(|k| *k < at);
779                    knots.insert(index, at);
780                    at
781                }
782            };
783            run.mitres.push((knot, mitre));
784        }
785        Ok((run, knots))
786    }
787
788    /// The frames at `s` read as `reading`, the fraction `u` between
789    /// sections oriented `a` and `b`.
790    fn at(
791        &self,
792        s: Scalar,
793        reading: Reading,
794        a: &StationOrientation,
795        b: &StationOrientation,
796        u: Scalar,
797    ) -> GeomResult<RunFrame> {
798        if let Some((_, mitre)) = self.mitres.iter().find(|(knot, _)| *knot == s) {
799            let plain = |base: &SectionFrame| -> GeomResult<RunFrame> {
800                Ok(RunFrame::Plain {
801                    base: *base,
802                    turned: oriented(base, a, b, u)?,
803                    before: None,
804                    after: None,
805                })
806            };
807            return match reading {
808                Reading::Incoming => plain(&mitre.incoming),
809                Reading::Outgoing => plain(&mitre.outgoing),
810                Reading::Placed => Ok(RunFrame::Mitred {
811                    mitre: *mitre,
812                    incoming: oriented(&mitre.incoming, a, b, u)?,
813                    outgoing: oriented(&mitre.outgoing, a, b, u)?,
814                }),
815            };
816        }
817        // The run's end reads the piece it lies on; an interior station on
818        // a seam that is not mitred, the side it names (#301).
819        let side = if self.end == Some(s) {
820            SeamSide::Incoming
821        } else {
822            self.sides
823                .iter()
824                .find(|(at, _)| *at == s)
825                .map_or(SeamSide::Outgoing, |(_, side)| *side)
826        };
827        let base = self.basis.section_on(s, self.frame, side)?;
828        let plane = |(_, mitre): &(Scalar, Mitre)| (mitre.outgoing.point, mitre.normal);
829        let before = self.mitres.iter().find(|(knot, _)| *knot > s).map(plane);
830        let after = self
831            .mitres
832            .iter()
833            .rev()
834            .find(|(knot, _)| *knot < s)
835            .map(plane);
836        Ok(RunFrame::Plain {
837            turned: oriented(&base, a, b, u)?,
838            base,
839            before,
840            after,
841        })
842    }
843}
844
845/// A section point `local` (profile `x` lateral, `y` up) placed with the
846/// station offsets.
847fn place(section: &SectionFrame, offsets: &StationOffsets, local: Point2) -> Point3 {
848    section.place(
849        local.x + offsets.lateral,
850        local.y + offsets.vertical,
851        offsets.longitudinal,
852    )
853}
854
855/// The frame a section is placed in at the fraction `u` between two
856/// sections oriented `a` and `b`: `base` itself when neither is oriented,
857/// else `base` turned by the unit axes interpolated linearly in it.
858fn oriented(
859    base: &SectionFrame,
860    a: &StationOrientation,
861    b: &StationOrientation,
862    u: Scalar,
863) -> GeomResult<SectionFrame> {
864    if a.is_base() && b.is_base() {
865        return Ok(*base);
866    }
867    let unit = |orientation: &StationOrientation| {
868        orientation
869            .unit_axes()
870            .map_err(|detail| GeomError::InvalidInput(format!("station: {detail}")))
871    };
872    let ((axis_a, ref_a), (axis_b, ref_b)) = (unit(a)?, unit(b)?);
873    base.oriented(Some(axis_a.lerp(axis_b, u)), Some(ref_a.lerp(ref_b, u)))
874}
875
876/// A section point `local` placed in the `turned` frame (profile `x` along
877/// its lateral axis, `y` along its up) at the origin the `offsets` locate
878/// in the `base` frame.
879fn place_turned(
880    base: &SectionFrame,
881    turned: &SectionFrame,
882    offsets: &StationOffsets,
883    local: Point2,
884) -> Point3 {
885    base.place(offsets.lateral, offsets.vertical, offsets.longitudinal)
886        + local.x * turned.lateral
887        + local.y * turned.up
888}
889
890fn lerp(a: Scalar, b: Scalar, u: Scalar) -> Scalar {
891    a + (b - a) * u
892}
893
894fn lerp_offsets(a: &StationOffsets, b: &StationOffsets, u: Scalar) -> StationOffsets {
895    StationOffsets::new(
896        lerp(a.lateral, b.lateral, u),
897        lerp(a.vertical, b.vertical, u),
898        lerp(a.longitudinal, b.longitudinal, u),
899    )
900}
901
902/// Whether the meshed span between two sections `lo` and `hi` passes the
903/// sampling test: each placed point at the midpoint `mid` within `chord`
904/// of its chord's midpoint, and each wall quad between neighbours `(i, j)`
905/// within `chord` of flat ([`quad_spread`]), so the two triangles the loft
906/// cuts it into stay near the bilinear patch.
907fn passes(
908    lo: &[Point3],
909    hi: &[Point3],
910    mid: &[Point3],
911    edges: &[(usize, usize)],
912    chord: Scalar,
913) -> bool {
914    let straight = lo
915        .iter()
916        .zip(hi)
917        .zip(mid)
918        .all(|((p, q), m)| (*m - 0.5 * (*p + *q)).length() <= chord);
919    straight
920        && edges
921            .iter()
922            .all(|&(i, j)| quad_spread(lo[i], lo[j], hi[i], hi[j]) <= chord)
923}
924
925/// How far the wall quad `a -> b` over `c -> d` strays from flat: the
926/// smaller of its twist `|a - b - c + d| / 4` and the height of `d` above
927/// the plane through `a, b, c`. A planar quad (a linear taper along a
928/// straight directrix) needs no refinement.
929fn quad_spread(a: Point3, b: Point3, c: Point3, d: Point3) -> Scalar {
930    let twist = 0.25 * (a - b - c + d).length();
931    let normal = (b - a).cross(c - a).normalize_or_zero();
932    if normal == axiolid_core::Vec3::ZERO {
933        twist
934    } else {
935        twist.min(normal.dot(d - a).abs())
936    }
937}
938
939/// Distances at which an interval `[a, b]` is meshed, `a` included and `b`
940/// excluded: bisected until [`passes`] holds and `accept` takes the cell
941/// (#247: its certified bound within the budget), at most [`MAX_DEPTH`]
942/// deep.
943///
944/// The ends are read as the pieces inside the interval see them (#263): a
945/// mitre's knot at `a` as its outgoing piece's plain section, at `b` as its
946/// incoming piece's, so an interval beside a mitre is sampled as that
947/// piece alone would be. A midpoint section a mitre would cut (`None`) is
948/// not inserted: the interval is joined straight to its end instead.
949/// `accept` bounds the cell by the rows the mesh stands (#335): at a mitre's
950/// knot the mitred row, not the plain section the sampling test reads.
951fn refine<F, A>(
952    a: Scalar,
953    b: Scalar,
954    chord: Scalar,
955    edges: &[(usize, usize)],
956    eval: &F,
957    accept: &A,
958    out: &mut Vec<Scalar>,
959) -> GeomResult<()>
960where
961    F: Fn(Scalar, Reading) -> GeomResult<Option<Vec<Point3>>>,
962    A: Fn(Scalar, Scalar, &[Point3], &[Point3]) -> GeomResult<bool>,
963{
964    let start = eval(a, Reading::Outgoing)?.ok_or_else(cut_by_mitre)?;
965    let end = eval(b, Reading::Incoming)?.ok_or_else(cut_by_mitre)?;
966    // The mesh's rows at the ends, which `accept` bounds the cells by: a
967    // mitre's knot is the mitred row there, not the plain section (#335).
968    let start_row = eval(a, Reading::Placed)?.ok_or_else(cut_by_mitre)?;
969    let end_row = eval(b, Reading::Placed)?.ok_or_else(cut_by_mitre)?;
970    // Depth-first, left half first, so distances come out increasing.
971    let mut stack = vec![(a, b, start, end, 0u32)];
972    while let Some((lo, hi, at_lo, at_hi, depth)) = stack.pop() {
973        let mid = 0.5 * (lo + hi);
974        let at_mid = eval(mid, Reading::Placed)?;
975        let straight = match &at_mid {
976            None => true,
977            Some(at_mid) => {
978                let row_lo = if lo == a { &start_row } else { &at_lo };
979                let row_hi = if hi == b { &end_row } else { &at_hi };
980                passes(&at_lo, &at_hi, at_mid, edges, chord) && accept(lo, hi, row_lo, row_hi)?
981            }
982        };
983        match at_mid {
984            Some(at_mid) if !(straight || depth >= MAX_DEPTH || !(mid > lo && mid < hi)) => {
985                stack.push((mid, hi, at_mid.clone(), at_hi, depth + 1));
986                stack.push((lo, mid, at_lo, at_mid, depth + 1));
987            }
988            _ => out.push(lo),
989        }
990        if out.len() > MAX_SECTIONS {
991            return Err(GeomError::BudgetExceeded {
992                resource: "station sections",
993            });
994        }
995    }
996    Ok(())
997}
998
999/// Every distance a run of stations is meshed at, first and last included.
1000fn sample_run<F, A>(
1001    distances: &[Scalar],
1002    chord: Scalar,
1003    edges: &[(usize, usize)],
1004    eval: F,
1005    accept: A,
1006) -> GeomResult<Vec<Scalar>>
1007where
1008    F: Fn(Scalar, Reading) -> GeomResult<Option<Vec<Point3>>>,
1009    A: Fn(Scalar, Scalar, &[Point3], &[Point3]) -> GeomResult<bool>,
1010{
1011    let mut out = Vec::new();
1012    for pair in distances.windows(2) {
1013        refine(pair[0], pair[1], chord, edges, &eval, &accept, &mut out)?;
1014    }
1015    if let Some(last) = distances.last() {
1016        out.push(*last);
1017    }
1018    Ok(out)
1019}
1020
1021/// The station interval `[i, i + 1]` containing `s` and the fraction
1022/// along it.
1023fn interval(distances: &[Scalar], s: Scalar) -> (usize, Scalar) {
1024    let last = distances.len() - 2;
1025    let i = distances
1026        .windows(2)
1027        .position(|pair| s <= pair[1])
1028        .unwrap_or(last)
1029        .min(last);
1030    let (a, b) = (distances[i], distances[i + 1]);
1031    (i, ((s - a) / (b - a)).clamp(0.0, 1.0))
1032}
1033
1034/// The point of an offset curve by stations at distance `s`, read as
1035/// `reading`; `None` where a mitre would cut it.
1036fn offset_point(
1037    run: &Run<'_>,
1038    stations: &[RunStation],
1039    distances: &[Scalar],
1040    s: Scalar,
1041    reading: Reading,
1042) -> GeomResult<Option<Point3>> {
1043    let (i, u) = interval(distances, s);
1044    let offsets = lerp_offsets(
1045        &stations[i].station.offsets,
1046        &stations[i + 1].station.offsets,
1047        u,
1048    );
1049    let base = StationOrientation::default();
1050    Ok(run
1051        .at(s, reading, &base, &base, u)?
1052        .place(&offsets, Point2::ZERO))
1053}
1054
1055/// An [`CurveRelation::OffsetByStations`] or
1056/// [`CurveRelation::OffsetsAtStations`] sampled as a sweep directrix: its
1057/// parameter is the basis distance, so `range` narrows the run of
1058/// stations, at their snapped distances (#301).
1059pub(crate) fn offset_curve_points(
1060    graph: &GeometryGraph,
1061    relation: &CurveRelation,
1062    range: Option<(Scalar, Scalar)>,
1063    options: &ExecutionOptions,
1064) -> GeomResult<Vec<Point3>> {
1065    let (basis, stations, frame) = offset_run(relation)?;
1066    if stations.len() < 2 {
1067        return Err(GeomError::InvalidInput(
1068            "an offset curve by stations needs at least two stations".into(),
1069        ));
1070    }
1071    let basis = Basis::of(graph, basis)?;
1072    let distances = snapped_run(
1073        |distance, window| basis.snapped_distance(distance, window),
1074        resolved_run(|| basis.length(), &stations)?,
1075    )?;
1076    let sides: RunSides = distances
1077        .iter()
1078        .zip(stations.iter())
1079        .map(|(at, station)| (*at, station.seam))
1080        .collect();
1081    let (first, last) = (distances[0], distances[distances.len() - 1]);
1082    let mut knots = distances.clone();
1083    if let Some((a, b)) = range {
1084        if !(a.is_finite() && b.is_finite()) || a.min(b) < first || a.max(b) > last {
1085            return Err(GeomError::InvalidInput(format!(
1086                "range ({a}, {b}) leaves the offset curve's span [{first}, {last}]"
1087            )));
1088        }
1089        let (lo, hi) = (a.min(b), a.max(b));
1090        knots.retain(|&d| d > lo && d < hi);
1091        knots.insert(0, lo);
1092        knots.push(hi);
1093    }
1094    let (run, knots) = Run::new(basis, frame, knots, sides)?;
1095    let chord = crate::compiler::chord_error(options);
1096    let at = |s, reading| offset_point(&run, &stations, &distances, s, reading);
1097    let samples = sample_run(
1098        &knots,
1099        chord,
1100        &[],
1101        |s, reading| at(s, reading).map(|p| p.map(|p| vec![p])),
1102        |_, _, _, _| Ok(true),
1103    )?;
1104    let mut points = samples
1105        .into_iter()
1106        .map(|s| at(s, Reading::Placed)?.ok_or_else(cut_by_mitre))
1107        .collect::<GeomResult<Vec<_>>>()?;
1108    if let Some((a, b)) = range {
1109        if a > b {
1110            points.reverse();
1111        }
1112    }
1113    Ok(points)
1114}
1115
1116/// Linear interpolation of two ring sets of one structure.
1117fn lerp_rings(a: &Rings, b: &Rings, u: Scalar) -> Rings {
1118    let mix = |p: &Point2, q: &Point2| Point2::new(lerp(p.x, q.x, u), lerp(p.y, q.y, u));
1119    Rings {
1120        outer: a
1121            .outer
1122            .iter()
1123            .zip(&b.outer)
1124            .map(|(p, q)| mix(p, q))
1125            .collect(),
1126        holes: a
1127            .holes
1128            .iter()
1129            .zip(&b.holes)
1130            .map(|(ha, hb)| ha.iter().zip(hb).map(|(p, q)| mix(p, q)).collect())
1131            .collect(),
1132    }
1133}
1134
1135fn same_structure(a: &Rings, b: &Rings) -> bool {
1136    a.outer.len() == b.outer.len()
1137        && a.holes.len() == b.holes.len()
1138        && a.holes
1139            .iter()
1140            .zip(&b.holes)
1141            .all(|(p, q)| p.len() == q.len())
1142}
1143
1144/// Mesh a [`SolidOperation::StationedSpine`](axiolid_model::SolidOperation::StationedSpine).
1145///
1146/// The profiles are flattened to half the chord budget and the stations
1147/// sampled to the other half, as the other doubly curved sweeps split it.
1148pub(crate) fn stationed_spine(
1149    graph: &GeometryGraph,
1150    directrix: NodeId,
1151    sections: &[SectionAtStation],
1152    frame: StationFrame,
1153    options: &ExecutionOptions,
1154) -> GeomResult<TriMesh> {
1155    stationed_spine_bounded(graph, directrix, sections, frame, options).map(|(mesh, _)| mesh)
1156}
1157
1158/// [`stationed_spine`] and the deviation of its mesh (#247): the
1159/// profile's flattening bound plus the worst cell's certified bound
1160/// ([`bound`]), or the reason it is unbounded.
1161pub(crate) fn stationed_spine_bounded(
1162    graph: &GeometryGraph,
1163    directrix: NodeId,
1164    sections: &[SectionAtStation],
1165    frame: StationFrame,
1166    options: &ExecutionOptions,
1167) -> GeomResult<(TriMesh, DeviationBound)> {
1168    let basis = Basis::of(graph, directrix)?;
1169    let half = 0.5 * crate::compiler::chord_error(options);
1170    let (rings, tagged, paired) = spine_rings(graph, sections, half, options.tolerance())?;
1171    let (distances, sides) = spine_distances(&basis, sections)?;
1172    let (run, knots) = Run::new(basis, frame, distances.clone(), sides)?;
1173    let placed = |s: Scalar, reading: Reading| -> GeomResult<Option<(Rings, Vec<Point3>)>> {
1174        let (i, u) = interval(&distances, s);
1175        let local = lerp_rings(&rings[i], &rings[i + 1], u);
1176        let offsets = lerp_offsets(
1177            &sections[i].station.offsets,
1178            &sections[i + 1].station.offsets,
1179            u,
1180        );
1181        let at = run.at(
1182            s,
1183            reading,
1184            &sections[i].orientation,
1185            &sections[i + 1].orientation,
1186            u,
1187        )?;
1188        let points: Option<Vec<Point3>> = local
1189            .outer
1190            .iter()
1191            .chain(local.holes.iter().flatten())
1192            .map(|p| at.place(&offsets, *p))
1193            .collect();
1194        Ok(points.map(|points| (local, points)))
1195    };
1196    let edges = wall_edges(&rings[0]);
1197    let profile = profile_bound(graph, sections, tagged, paired, half, options.tolerance())?;
1198    let law = bound::SectionLaw::new(
1199        &run,
1200        &distances,
1201        sections.iter().map(|s| s.station.offsets).collect(),
1202        sections.iter().map(|s| s.orientation).collect(),
1203        ring_points(&rings),
1204        &edges,
1205        half,
1206    )?;
1207    // Sections that fold are no solid (#361): refused where the planes
1208    // are certified to stop advancing.
1209    let fold = match &profile {
1210        Ok(d) => law.fold(*d)?,
1211        Err(_) => Ok(()),
1212    };
1213    let samples = sample_run(
1214        &knots,
1215        half,
1216        &edges,
1217        |s, reading| placed(s, reading).map(|section| section.map(|(_, points)| points)),
1218        |lo, hi, at_lo, at_hi| {
1219            within(
1220                &law,
1221                profile.is_ok() && fold.is_ok(),
1222                half,
1223                lo,
1224                hi,
1225                at_lo,
1226                at_hi,
1227            )
1228        },
1229    )?;
1230    let mut stations = Vec::with_capacity(samples.len());
1231    let mut rows = Vec::with_capacity(samples.len());
1232    for s in &samples {
1233        let (local, points) = placed(*s, Reading::Placed)?.ok_or_else(cut_by_mitre)?;
1234        rows.push(points.clone());
1235        let mut it = points.into_iter();
1236        let mut loops = Vec::with_capacity(1 + local.holes.len());
1237        loops.push(it.by_ref().take(local.outer.len()).collect());
1238        for hole in &local.holes {
1239            loops.push(it.by_ref().take(hole.len()).collect());
1240        }
1241        stations.push(LoftStation { loops });
1242    }
1243    let bound = settle(&law, profile, fold, &samples, &rows)?;
1244    let mesh = loft_tapered(&rings[0], &rings[rings.len() - 1], &stations)?;
1245    Ok((mesh, bound))
1246}
1247
1248/// The wall edges of a spine's sections: each ring of `first` closes on
1249/// itself, the vertices numbered outer ring first, then the holes.
1250fn wall_edges(first: &Rings) -> Vec<(usize, usize)> {
1251    let mut edges = Vec::new();
1252    let mut base = 0;
1253    for len in std::iter::once(first.outer.len()).chain(first.holes.iter().map(Vec::len)) {
1254        edges.extend((0..len).map(|k| (base + k, base + (k + 1) % len)));
1255        base += len;
1256    }
1257    edges
1258}
1259
1260/// Every section's vertices, outer ring first, then the holes.
1261fn ring_points(rings: &[Rings]) -> Vec<Vec<Point2>> {
1262    rings
1263        .iter()
1264        .map(|r| {
1265            r.outer
1266                .iter()
1267                .chain(r.holes.iter().flatten())
1268                .copied()
1269                .collect()
1270        })
1271        .collect()
1272}
1273
1274/// Refuses a spine whose section planes fold (#361) before an exact
1275/// compiler builds it, as the mesh compiler does: the same check
1276/// ([`bound::SectionLaw::fold`]). A run whose fold cannot be excluded is
1277/// one the exact compiler cannot vouch for either, so it is refused by name
1278/// as `unsupported` builds it.
1279///
1280/// # Errors
1281///
1282/// A fold, or a touch within rounding, as invalid input; a fold that cannot
1283/// be excluded, by name; the check's budget, by name.
1284#[allow(clippy::too_many_arguments)]
1285pub(super) fn refuse_fold(
1286    graph: &GeometryGraph,
1287    basis: Basis<'_>,
1288    sections: &[SectionAtStation],
1289    frame: StationFrame,
1290    rings: &[Rings],
1291    (tagged, paired): (bool, Option<Scalar>),
1292    (distances, sides): (Vec<Scalar>, RunSides),
1293    options: &ExecutionOptions,
1294    unsupported: fn(&'static str) -> GeomError,
1295) -> GeomResult<()> {
1296    let half = 0.5 * crate::compiler::chord_error(options);
1297    let Ok(margin) = profile_bound(graph, sections, tagged, paired, half, options.tolerance())?
1298    else {
1299        return Ok(());
1300    };
1301    let (run, _) = Run::new(basis, frame, distances.clone(), sides)?;
1302    let edges = wall_edges(&rings[0]);
1303    let law = bound::SectionLaw::new(
1304        &run,
1305        &distances,
1306        sections.iter().map(|s| s.station.offsets).collect(),
1307        sections.iter().map(|s| s.orientation).collect(),
1308        ring_points(rings),
1309        &edges,
1310        half,
1311    )?;
1312    law.fold(margin)?.map_err(unsupported)
1313}
1314
1315/// A spine's section rings, each matched to the first's vertex for vertex
1316/// (by tag, or by ring and index), the untagged ones flattened to
1317/// `budget`; whether the sections are tagged; and, for untagged sections
1318/// whose different curved profiles pair segment for segment (#361), the
1319/// deviation of the rings flattened together.
1320pub(super) fn spine_rings(
1321    graph: &GeometryGraph,
1322    sections: &[SectionAtStation],
1323    budget: Scalar,
1324    tolerance: Tolerance,
1325) -> GeomResult<(Vec<Rings>, bool, Option<Scalar>)> {
1326    if sections.len() < 2 {
1327        return Err(GeomError::InvalidInput(
1328            "a station-placed spine needs at least two sections".into(),
1329        ));
1330    }
1331    let tagged = !sections[0].tags.is_empty();
1332    if sections
1333        .iter()
1334        .any(|section| section.tags.is_empty() == tagged)
1335    {
1336        return Err(GeomError::InvalidInput(
1337            "station-placed spine sections mix tagged and untagged sections: tag every \
1338             section or none"
1339                .into(),
1340        ));
1341    }
1342    let shape = |section: &SectionAtStation| match graph.get(section.profile) {
1343        Some(GeometryNode::Profile(shape)) => Ok(shape),
1344        _ => Err(GeomError::InvalidInput(format!(
1345            "spine section profile {:?} is not a Profile node",
1346            section.profile
1347        ))),
1348    };
1349    let mut paired = None;
1350    let rings = if tagged {
1351        let tagged = sections
1352            .iter()
1353            .map(|section| tagged_rings(shape(section)?, &section.tags, tolerance))
1354            .collect::<GeomResult<Vec<_>>>()?;
1355        let first = &tagged[0].1;
1356        tagged
1357            .iter()
1358            .map(|(rings, tags)| match_rings(first, rings, tags))
1359            .collect::<GeomResult<Vec<_>>>()?
1360    } else {
1361        let alone = sections
1362            .iter()
1363            .map(|section| profile_rings(shape(section)?, budget, tolerance))
1364            .collect::<GeomResult<Vec<_>>>()?;
1365        // Different curved profiles flatten together where they pair.
1366        let profiles = sections.iter().map(shape).collect::<GeomResult<Vec<_>>>()?;
1367        let one = sections.iter().all(|s| {
1368            s.profile == sections[0].profile
1369                || graph.get(s.profile) == graph.get(sections[0].profile)
1370        });
1371        let mut curved = false;
1372        for profile in &profiles {
1373            curved |=
1374                profile_deviation(profile, budget, tolerance)? != ProfileDeviation::Bounded(0.0);
1375        }
1376        if !one && !curved && profiles.iter().any(|p| mirrors(p) != mirrors(profiles[0])) {
1377            return Err(GeomError::InvalidInput(
1378                "station-placed spine sections: a mirrored profile against an unmirrored one \
1379                 reverses its ring, so vertices matched by index lie on opposite sides; tag \
1380                 the sections or mirror every one"
1381                    .into(),
1382            ));
1383        }
1384        let together = if one || !curved {
1385            None
1386        } else {
1387            paired_profile_rings(&profiles, budget, tolerance)?
1388        };
1389        match together {
1390            Some(together) => {
1391                paired = Some(together.deviation);
1392                together.rings
1393            }
1394            None => alone,
1395        }
1396    };
1397    if rings.iter().any(|ring| !same_structure(ring, &rings[0])) {
1398        return Err(GeomError::InvalidInput(
1399            "station-placed spine sections must share their ring structure: vertices are \
1400             matched by ring and index"
1401                .into(),
1402        ));
1403    }
1404    Ok((rings, tagged, paired))
1405}
1406
1407/// Whether a profile's placement chain mirrors it (an odd number of
1408/// transforms of negative determinant), which reverses its flattened ring.
1409fn mirrors(profile: &Profile) -> bool {
1410    match profile {
1411        Profile::Derived { basis, transform } => {
1412            mirrors(basis) != (transform.matrix2.determinant() < 0.0)
1413        }
1414        _ => false,
1415    }
1416}
1417
1418/// A spine's stations' distances, increasing as given and snapped by
1419/// their windows (#301), with the side each names.
1420pub(super) fn spine_distances(
1421    basis: &Basis<'_>,
1422    sections: &[SectionAtStation],
1423) -> GeomResult<(Vec<Scalar>, RunSides)> {
1424    if sections
1425        .windows(2)
1426        .any(|pair| pair[1].station.distance <= pair[0].station.distance)
1427    {
1428        return Err(GeomError::InvalidInput(
1429            "station-placed spine distances must increase strictly".into(),
1430        ));
1431    }
1432    snapped_sections(basis, sections)
1433}
1434
1435/// Why sections with different curved profiles that do not pair segment for
1436/// segment are unbounded (#247, #361).
1437const CURVED_TAPER: &str =
1438    "stationed sections: different curved profiles at the stations that do not pair segment \
1439     for segment (another segment family, a polyline segment, a different segment structure) \
1440     have no exact vertex correspondence between them";
1441
1442/// The profile's share of a spine's bound (#247): `0` for straight-edged
1443/// sections (their rings are the exact profiles, a tagged section's
1444/// always), the worst flattening bound where every section is one curved
1445/// profile (the interpolation between a profile and itself is that
1446/// profile, placed rigidly), the `paired` bound where different curved
1447/// profiles were flattened together (#361), else the reason it is
1448/// unbounded.
1449fn profile_bound(
1450    graph: &GeometryGraph,
1451    sections: &[SectionAtStation],
1452    tagged: bool,
1453    paired: Option<Scalar>,
1454    budget: Scalar,
1455    tolerance: Tolerance,
1456) -> GeomResult<Result<Scalar, &'static str>> {
1457    if tagged {
1458        return Ok(Ok(0.0));
1459    }
1460    // Different curved profiles flattened together (#361).
1461    if let Some(bound) = paired {
1462        return Ok(Ok(bound));
1463    }
1464    let mut worst: Scalar = 0.0;
1465    for section in sections {
1466        let Some(GeometryNode::Profile(shape)) = graph.get(section.profile) else {
1467            return Ok(Err(CURVED_TAPER));
1468        };
1469        match profile_deviation(shape, budget, tolerance)? {
1470            ProfileDeviation::Bounded(d) => worst = worst.max(d),
1471            ProfileDeviation::Unbounded(reason) => return Ok(Err(reason)),
1472            _ => return Ok(Err("profile deviation")),
1473        }
1474    }
1475    let first = graph.get(sections[0].profile);
1476    let one = sections
1477        .iter()
1478        .all(|s| s.profile == sections[0].profile || graph.get(s.profile) == first);
1479    Ok(if worst == 0.0 || one {
1480        Ok(worst)
1481    } else {
1482        Err(CURVED_TAPER)
1483    })
1484}
1485
1486/// Whether a sampled cell is fine enough (#247): its certified bound within
1487/// `budget`, or any cell where the run is not bounded (the sampling test
1488/// alone decides there). A cell beside a mitre is split while its bound is
1489/// past the budget unless its floor is (#335: the gap no splitting removes),
1490/// and while it is unbounded for a reason a split can remove.
1491fn within(
1492    law: &bound::SectionLaw<'_, '_>,
1493    bounded: bool,
1494    budget: Scalar,
1495    lo: Scalar,
1496    hi: Scalar,
1497    at_lo: &[Point3],
1498    at_hi: &[Point3],
1499) -> GeomResult<bool> {
1500    if !bounded {
1501        return Ok(true);
1502    }
1503    Ok(match law.cell(lo, hi, at_lo, at_hi)? {
1504        // Past the budget only if splitting can help: it cannot remove the
1505        // floor.
1506        Ok(cell) => cell.value <= budget || cell.floor > budget,
1507        Err(reason) => !bound::refinable(reason),
1508    })
1509}
1510
1511/// A run's deviation (#247): `fixed` (the profile's share) plus the worst
1512/// certified bound over the meshed cells between consecutive `samples`,
1513/// whose placed points are `rows`; unbounded by the first reason met: the
1514/// profile's, a fold that cannot be excluded (#361), then a cell's.
1515fn settle(
1516    law: &bound::SectionLaw<'_, '_>,
1517    fixed: Result<Scalar, &'static str>,
1518    fold: Result<(), &'static str>,
1519    samples: &[Scalar],
1520    rows: &[Vec<Point3>],
1521) -> GeomResult<DeviationBound> {
1522    let mut worst = match fixed {
1523        Ok(value) => value,
1524        Err(reason) => return Ok(DeviationBound::Unbounded(reason)),
1525    };
1526    if let Err(reason) = fold {
1527        return Ok(DeviationBound::Unbounded(reason));
1528    }
1529    let mut cells: Scalar = 0.0;
1530    for (pair, points) in samples.windows(2).zip(rows.windows(2)) {
1531        match law.cell(pair[0], pair[1], &points[0], &points[1])? {
1532            Ok(cell) => cells = cells.max(cell.value),
1533            Err(reason) => return Ok(DeviationBound::Unbounded(reason)),
1534        }
1535    }
1536    worst += cells;
1537    Ok(DeviationBound::Certified(worst))
1538}
1539
1540/// Why a tagged closed section cannot be matched (#246).
1541fn tag_mismatch(detail: &str) -> GeomError {
1542    GeomError::InvalidInput(format!("station-placed spine tags: {detail}"))
1543}
1544
1545/// A tagged closed section's rings, wound outer counter-clockwise and holes
1546/// clockwise, each with its tags in ring order. The tags name the contour
1547/// vertices, the outer ring's then each hole's, in authored order.
1548fn tagged_rings(
1549    profile: &Profile,
1550    tags: &[String],
1551    tolerance: Tolerance,
1552) -> GeomResult<(Rings, Vec<Vec<String>>)> {
1553    let mut rings = polygon_rings(profile, tolerance)?;
1554    let count: usize = rings.iter().map(Vec::len).sum();
1555    if count != tags.len() {
1556        return Err(tag_mismatch(&format!(
1557            "a section has {} tags for {count} contour vertices",
1558            tags.len()
1559        )));
1560    }
1561    let mut names = tags.iter();
1562    let mut ring_tags = Vec::with_capacity(rings.len());
1563    for (k, ring) in rings.iter_mut().enumerate() {
1564        let mut ring_names: Vec<String> = names.by_ref().take(ring.len()).cloned().collect();
1565        let area = axiolid_reference::signed_area2(ring);
1566        if area == 0.0 || !area.is_finite() {
1567            return Err(GeomError::Degenerate(
1568                "a tagged spine section has a ring without area".into(),
1569            ));
1570        }
1571        // The outer ring counter-clockwise, holes clockwise, as
1572        // `profile_rings` winds them; the tags turn with their vertices.
1573        if (area > 0.0) != (k == 0) {
1574            ring.reverse();
1575            ring_names.reverse();
1576        }
1577        ring_tags.push(ring_names);
1578    }
1579    let outer = rings.remove(0);
1580    Ok((
1581        Rings {
1582            outer,
1583            holes: rings,
1584        },
1585        ring_tags,
1586    ))
1587}
1588
1589/// The vertex rings of a polygonal profile, outer first, in authored order.
1590fn polygon_rings(profile: &Profile, tolerance: Tolerance) -> GeomResult<Vec<Vec<Point2>>> {
1591    match profile {
1592        Profile::Contour(contour) => std::iter::once(&contour.outer)
1593            .chain(&contour.holes)
1594            .map(|ring| polygon_vertices(ring, tolerance))
1595            .collect(),
1596        Profile::Derived { basis, transform } => {
1597            let mut rings = polygon_rings(basis, tolerance)?;
1598            for point in rings.iter_mut().flatten() {
1599                *point = transform.transform_point2(*point);
1600            }
1601            Ok(rings)
1602        }
1603        _ => Err(GeomError::UnsupportedInput {
1604            backend: crate::BACKEND_ID,
1605            operation: Operation::ProfileTriangulation,
1606            input: "a tagged spine section whose profile is not a contour: tags name contour \
1607                    vertices",
1608        }),
1609    }
1610}
1611
1612/// A closed contour's vertices: each straight segment's points in the
1613/// contour's sense, coincident neighbours merged, the closing repeat
1614/// dropped.
1615fn polygon_vertices(contour: &Contour, tolerance: Tolerance) -> GeomResult<Vec<Point2>> {
1616    let linear = tolerance.linear();
1617    let near = |a: Point2, b: Point2| (a.x - b.x).abs() <= linear && (a.y - b.y).abs() <= linear;
1618    let mut out: Vec<Point2> = Vec::new();
1619    for segment in &contour.segments {
1620        if !matches!(segment.curve, Curve2::Line(_) | Curve2::Polyline(_)) {
1621            return Err(GeomError::UnsupportedInput {
1622                backend: crate::BACKEND_ID,
1623                operation: Operation::ProfileTriangulation,
1624                input: "a tagged spine section with a curved contour segment: tags name the \
1625                        vertices of straight segments",
1626            });
1627        }
1628        // A line or a polyline flattens to its own vertices whatever the
1629        // chord budget.
1630        let mut points =
1631            axiolid_reference::curve::flatten2(&segment.curve, segment.domain, 1.0, 16)?;
1632        if !segment.same_sense {
1633            points.reverse();
1634        }
1635        for point in points {
1636            if !out.last().is_some_and(|last| near(*last, point)) {
1637                out.push(point);
1638            }
1639        }
1640    }
1641    while out.len() > 1 && near(out[0], out[out.len() - 1]) {
1642        out.pop();
1643    }
1644    if out.len() < 3 {
1645        return Err(GeomError::Degenerate(format!(
1646            "a tagged spine section's contour has {} vertices, need at least 3",
1647            out.len()
1648        )));
1649    }
1650    Ok(out)
1651}
1652
1653/// `rings` re-ordered so that ring `r`, vertex `m` carries the tag
1654/// `first[r][m]`: the first section's ring structure, matched by tag.
1655fn match_rings(first: &[Vec<String>], rings: &Rings, tags: &[Vec<String>]) -> GeomResult<Rings> {
1656    let index: std::collections::HashMap<&str, (usize, usize)> = tags
1657        .iter()
1658        .enumerate()
1659        .flat_map(|(r, ring)| {
1660            ring.iter()
1661                .enumerate()
1662                .map(move |(m, tag)| (tag.as_str(), (r, m)))
1663        })
1664        .collect();
1665    let points = |r: usize| {
1666        if r == 0 {
1667            &rings.outer
1668        } else {
1669            &rings.holes[r - 1]
1670        }
1671    };
1672    if tags.len() != first.len() {
1673        return Err(tag_mismatch("the sections have different numbers of rings"));
1674    }
1675    let mut matched: Vec<Vec<Point2>> = Vec::with_capacity(first.len());
1676    for (r, want) in first.iter().enumerate() {
1677        let Some(&(found, start)) = want.first().and_then(|tag| index.get(tag.as_str())) else {
1678            return Err(tag_mismatch(
1679                "a section lacks a tag the first section carries",
1680            ));
1681        };
1682        let have = &tags[found];
1683        let len = want.len();
1684        let cyclic = have.len() == len && (0..len).all(|m| have[(start + m) % len] == want[m]);
1685        if (r == 0) != (found == 0) || !cyclic {
1686            return Err(tag_mismatch(
1687                "the tags must map each ring of the first section onto one ring of every \
1688                 other, the outer onto the outer, in the same cyclic order",
1689            ));
1690        }
1691        let ring = points(found);
1692        matched.push((0..len).map(|m| ring[(start + m) % len]).collect());
1693    }
1694    let outer = matched.remove(0);
1695    Ok(Rings {
1696        outer,
1697        holes: matched,
1698    })
1699}
1700
1701/// The open polyline of a sectioned surface's section, checked against
1702/// its tags.
1703fn open_section(graph: &GeometryGraph, section: &SectionAtStation) -> GeomResult<Vec<Point2>> {
1704    let path = match graph.get(section.profile) {
1705        Some(GeometryNode::OpenProfile(profile)) => profile.path,
1706        _ => {
1707            return Err(GeomError::InvalidInput(format!(
1708                "sectioned surface section {:?} is not an OpenProfile node",
1709                section.profile
1710            )))
1711        }
1712    };
1713    let points = match graph.get(path) {
1714        Some(GeometryNode::Curve2(Curve2::Polyline(polyline))) if !polyline.closed => {
1715            polyline.points.clone()
1716        }
1717        _ => {
1718            return Err(GeomError::UnsupportedInput {
1719                backend: crate::BACKEND_ID,
1720                operation: Operation::CurveEvaluation,
1721                input: "a sectioned surface section that is not an open polyline: its tags \
1722                        name polyline vertices",
1723            })
1724        }
1725    };
1726    if !section.tags.is_empty() && section.tags.len() != points.len() {
1727        return Err(GeomError::InvalidInput(format!(
1728            "a sectioned surface section has {} tags for {} vertices",
1729            section.tags.len(),
1730            points.len()
1731        )));
1732    }
1733    Ok(points)
1734}
1735
1736/// Mesh a [`SurfaceRelation::SectionedSurface`](axiolid_model::SurfaceRelation::SectionedSurface)
1737/// as an open sheet, with its deviation (#247): the worst cell's certified
1738/// bound ([`bound`]; an open section is a polyline, its own exact
1739/// section), or the reason it is unbounded. Sections whose planes fold are
1740/// no sheet (#407): refused as a spine is, with no flattening margin.
1741///
1742/// # Errors
1743///
1744/// A fold, or a touch within rounding, is `InvalidInput`; the bisection
1745/// budget fails by name with `BudgetExceeded`.
1746pub(crate) fn sectioned_surface(
1747    graph: &GeometryGraph,
1748    directrix: NodeId,
1749    sections: &[SectionAtStation],
1750    frame: StationFrame,
1751    options: &ExecutionOptions,
1752) -> GeomResult<(TriMesh, DeviationBound)> {
1753    if sections.len() < 2 {
1754        return Err(GeomError::InvalidInput(
1755            "a sectioned surface needs at least two sections".into(),
1756        ));
1757    }
1758    let basis = Basis::of(graph, directrix)?;
1759    let mut polylines = sections
1760        .iter()
1761        .map(|section| open_section(graph, section))
1762        .collect::<GeomResult<Vec<_>>>()?;
1763    let count = polylines[0].len();
1764    if count < 2 || polylines.iter().any(|points| points.len() != count) {
1765        return Err(GeomError::InvalidInput(
1766            "sectioned surface sections must have one vertex per tag, at least two, and the \
1767             same count"
1768                .into(),
1769        ));
1770    }
1771    // Joined by tag: a section whose tags run backwards is joined reversed.
1772    let first = &sections[0].tags;
1773    for (section, points) in sections.iter().zip(&mut polylines) {
1774        if section.tags == *first {
1775            continue;
1776        }
1777        if section.tags.iter().eq(first.iter().rev()) {
1778            points.reverse();
1779        } else {
1780            return Err(GeomError::InvalidInput(
1781                "sectioned surface sections carry inconsistent tags: every section must carry \
1782                 the first section's tags in its order or in reverse, or none"
1783                    .into(),
1784            ));
1785        }
1786    }
1787    let distances: Vec<Scalar> = sections.iter().map(|s| s.station.distance).collect();
1788    if distances.windows(2).any(|pair| pair[1] <= pair[0]) {
1789        return Err(GeomError::InvalidInput(
1790            "sectioned surface distances must increase strictly".into(),
1791        ));
1792    }
1793    let (distances, sides) = snapped_sections(&basis, sections)?;
1794    let (run, knots) = Run::new(basis, frame, distances.clone(), sides)?;
1795    let placed = |s: Scalar, reading: Reading| -> GeomResult<Option<Vec<Point3>>> {
1796        let (i, u) = interval(&distances, s);
1797        let offsets = lerp_offsets(
1798            &sections[i].station.offsets,
1799            &sections[i + 1].station.offsets,
1800            u,
1801        );
1802        let at = run.at(
1803            s,
1804            reading,
1805            &sections[i].orientation,
1806            &sections[i + 1].orientation,
1807            u,
1808        )?;
1809        Ok(polylines[i]
1810            .iter()
1811            .zip(&polylines[i + 1])
1812            .map(|(p, q)| at.place(&offsets, Point2::new(lerp(p.x, q.x, u), lerp(p.y, q.y, u))))
1813            .collect())
1814    };
1815    let chord = crate::compiler::chord_error(options);
1816    let edges: Vec<(usize, usize)> = (0..count - 1).map(|k| (k, k + 1)).collect();
1817    let law = bound::SectionLaw::new(
1818        &run,
1819        &distances,
1820        sections.iter().map(|s| s.station.offsets).collect(),
1821        sections.iter().map(|s| s.orientation).collect(),
1822        polylines.clone(),
1823        &edges,
1824        chord,
1825    )?;
1826    // Sections whose planes fold are no sheet either (#407): refused where
1827    // the planes are certified to stop advancing; an open section is its
1828    // own exact polyline, so no flattening margin.
1829    let fold = law.fold(0.0)?;
1830    let samples = sample_run(&knots, chord, &edges, placed, |lo, hi, at_lo, at_hi| {
1831        within(&law, fold.is_ok(), chord, lo, hi, at_lo, at_hi)
1832    })?;
1833    let mut positions = Vec::with_capacity(samples.len() * count);
1834    let mut rows = Vec::with_capacity(samples.len());
1835    for s in &samples {
1836        let row = placed(*s, Reading::Placed)?.ok_or_else(cut_by_mitre)?;
1837        positions.extend(row.iter().copied());
1838        rows.push(row);
1839    }
1840    let bound = settle(&law, Ok(0.0), fold, &samples, &rows)?;
1841    let mut indices = Vec::with_capacity((samples.len() - 1) * (count - 1) * 6);
1842    for row in 0..samples.len() - 1 {
1843        let a = (row * count) as u32;
1844        let b = ((row + 1) * count) as u32;
1845        for k in 0..count as u32 - 1 {
1846            indices.extend([a + k, a + k + 1, b + k + 1, a + k, b + k + 1, b + k]);
1847        }
1848    }
1849    let mesh = TriMesh::new(positions, indices);
1850    reject_degenerate(&mesh, options.tolerance())?;
1851    Ok((mesh, bound))
1852}
1853
1854/// Refuse a sheet with a collapsed triangle: two sections that place a
1855/// tag at one point would leave a sliver no consumer can orient.
1856fn reject_degenerate(mesh: &TriMesh, tolerance: Tolerance) -> GeomResult<()> {
1857    let floor = tolerance.linear() * tolerance.linear();
1858    for triangle in mesh.indices.as_chunks::<3>().0 {
1859        let [a, b, c] = [0, 1, 2].map(|k| mesh.positions[triangle[k] as usize]);
1860        if (b - a).cross(c - a).length() <= floor {
1861            return Err(GeomError::Degenerate(
1862                "a sectioned surface has a collapsed triangle between two sections".into(),
1863            ));
1864        }
1865    }
1866    Ok(())
1867}