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(§ion, &station.offsets, Point2::ZERO);
390 let turned = oriented(§ion, 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 §ions[i].station.offsets,
1178 §ions[i + 1].station.offsets,
1179 u,
1180 );
1181 let at = run.at(
1182 s,
1183 reading,
1184 §ions[i].orientation,
1185 §ions[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)?, §ion.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 = §ions[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 §ions[i].station.offsets,
1799 §ions[i + 1].station.offsets,
1800 u,
1801 );
1802 let at = run.at(
1803 s,
1804 reading,
1805 §ions[i].orientation,
1806 §ions[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}