Skip to main content

axiolid_model/
station.rs

1//! Stations: positions named by a distance along a basis curve (#241,
2//! ADR 0082).
3//!
4//! A station is exact stored data -- a distance and three offsets -- that a
5//! kernel resolves by evaluating the basis curve; the graph never does.
6//!
7//! # Distance
8//!
9//! [`Station::distance`] is measured from the basis curve's start in that
10//! curve's own convention: PLAN distance on an elevated or a banked curve
11//! (their parameter), arc length on every other curve, 2D or 3D. The start
12//! is parameter `0`, or the first parameter of the domain of a polyline or
13//! a B-spline. A resolver refuses by name a distance before the start or
14//! past the curve's length; the graph refuses a non-finite or negative one
15//! when the node is pushed.
16//!
17//! A basis may also be a curve relation a distance runs along (#285): a
18//! [`CurveRelation::Composite`], a [`CurveRelation::Trimmed`], a curve
19//! placed by an [`InstanceAtStation`], nested in any combination. The
20//! distance then runs end to end through the atomic curves it is made of,
21//! each span in its own curve's convention, and those must agree: plan
22//! distance when every span is an elevated or banked curve, arc length
23//! when none is. A composite of segments placed at stations of another
24//! curve is measured along its own segments, whatever curve they were
25//! placed on. Each joint of a composite is a seam (below), and its pieces
26//! must meet end to start; a resolver refuses by name pieces measured
27//! differently, a gap, and a piece whose undeclared sense runs it
28//! backwards.
29//!
30//! [`CurveRelation::Composite`]: crate::CurveRelation::Composite
31//! [`CurveRelation::Trimmed`]: crate::CurveRelation::Trimmed
32//!
33//! # Frame and offsets
34//!
35//! The basis curve's section frame at the distance has three unit axes:
36//! the tangent, the lateral axis to the LEFT of it, and `up = tangent x
37//! lateral`. For a 2D curve (lying in `z = 0`) they are the tangent, the
38//! left normal and `+Z`; for a banked curve, its rolled section (lateral
39//! towards the left rail head); for every other 3D curve, an elevated one
40//! included, the reference-up frame against `+Z` (lateral horizontal, up
41//! leaning back with the grade). [`StationFrame::Plan`] instead takes the
42//! tangent's horizontal projection, the horizontal left normal and `+Z`.
43//!
44//! The station's point is `point + lateral * l + up * v + tangent * g` for
45//! the offsets `(l, v, g)` of [`StationOffsets`]. A resolver presents the
46//! frame as `x` the tangent, `y` up, `z` to the right (`-lateral`), the
47//! curve-evaluation provider's layout.
48//!
49//! # Explicit orientation (#246)
50//!
51//! A station may carry a [`StationOrientation`]: an optional `axis` and an
52//! optional `ref_direction`, both given as COMPONENTS IN THE STATION'S BASE
53//! FRAME at its distance (the [`StationFrame`] it names), never in world
54//! coordinates: a vector `(a, b, c)` means `a * tangent + b * lateral + c *
55//! up`. This is how a linear placement reads its own axes: they "are
56//! relative to the curve used for linear referencing ..., maintaining the
57//! relationship to the tangent of the curve" (buildingSMART IFC 4.3,
58//! `IfcAxis2PlacementLinear`), whose local `X` is the tangent, `Y` the left
59//! lateral and `Z` up, this module's `(tangent, lateral, up)`.
60//!
61//! - `axis` is the exact direction of the oriented frame's up (local `Z`);
62//!   absent, it is `(0, 0, 1)`, the base frame's up.
63//! - `ref_direction` fixes the oriented frame's tangent role (local `X`);
64//!   absent, it is `(1, 0, 0)`, the curve tangent.
65//! - The frame is orthonormalised by Gram-Schmidt with the axis primary:
66//!   `up' = axis / |axis|`, `tangent' = normalise(r - (r . up') up')` for
67//!   the unit `r = ref_direction / |ref_direction|`, and `lateral' = up' x
68//!   tangent'`, so `(tangent', lateral', up')` stays right-handed.
69//! - A zero or non-finite vector, and an axis and reference direction
70//!   (given or defaulted) parallel or anti-parallel within
71//!   [`ORIENTATION_TOLERANCE`] (the sine of the angle between them), are
72//!   refused by name when the node is pushed.
73//!
74//! The oriented triad replaces the base one wherever a frame ORIENTS
75//! something: the frame a resolved station presents, and the plane a
76//! section's profile is placed in (profile `x` along `lateral'`, `y`
77//! along `up'`). The OFFSETS stay in the base frame: they locate the
78//! station's origin, which the orientation does not move.
79//!
80//! # Seams (#263)
81//!
82//! A basis curve made of pieces -- a polyline, a B-spline with a corner
83//! knot, an elevated curve whose profile's grade breaks, a banked curve
84//! whose cant or pivot law jumps -- has two frames where two pieces meet.
85//! A station within the arc-length tolerance of such a seam is ON it and
86//! reads the frame of one piece, chosen by [`SeamSide`]: the piece that
87//! starts there ([`SeamSide::Outgoing`], the default and what every curve
88//! evaluator reads) or the one that ends there ([`SeamSide::Incoming`]).
89//! An [`OrientedCurveStation`] carries the side ([`OrientedCurveStation::seam`]);
90//! a plain [`CurveStation`] reads the outgoing piece, and
91//! [`CurveStation::with_seam_side`] turns it into an oriented station in
92//! its base frame that reads the other one.
93//!
94//! A run of sections or offsets ([`Station`]s along one directrix) decides
95//! most sides itself: a section standing exactly on an interior seam where
96//! the tangent turns is cut in the mitre plane, the bisector of the
97//! incoming and outgoing tangents, and so is the run where it crosses such
98//! a seam between two of its stations. The run's first station reads the
99//! outgoing piece and its last the incoming one, the pieces the run lies
100//! on. A seam where the tangent does not turn keeps the outgoing frame,
101//! unless the station standing on it names the other side (#301, below).
102//!
103//! # Seam-snapping window (#294)
104//!
105//! An authoring rule may put a station ON a seam when it lies within the
106//! model's precision of one -- IFC 4.3 ADD2 8.9.3.48.3 lets the previous
107//! segment's tangent govern a station "within precision limits" of a
108//! tangent discontinuity -- and that precision (up to a millimetre) is far
109//! wider than the arc-length tolerance. An [`OrientedCurveStation`]
110//! carries it as [`OrientedCurveStation::seam_window`], a non-negative
111//! distance in the basis's station measure, set by
112//! [`OrientedCurveStation::with_seam_window`]; `0`, the default, is the
113//! arc-length tolerance alone.
114//!
115//! A resolver snaps a station whose distance lies within the window of a
116//! seam of its basis whose frame may jump (a joint of a composite, a
117//! polyline vertex, a corner knot, a grade or cant seam) onto that seam,
118//! and reads it from the station's [`SeamSide`]; the station's point is
119//! then the seam's. Where a seam's distance is numerical (after an offset
120//! piece, an ellipse or a B-spline), the resolver uses a certified
121//! interval of it. It refuses by name a window holding two seams, and a
122//! seam whose certified interval straddles the window's edge (inside or
123//! outside cannot be told). The graph refuses a negative or non-finite
124//! window when the node is pushed. A plain [`CurveStation`] has no window.
125//!
126//! # Seam windows on run stations (#301)
127//!
128//! The stations of a run carry the same window and a [`SeamSide`]: a
129//! [`SectionAtStation`] (in [`SolidOperation::SectionsAtStations`] and
130//! [`SurfaceRelation::OpenSectionsAtStations`]) through
131//! [`SectionAtStation::with_seam_window`] and
132//! [`SectionAtStation::with_seam_side`], an offset's [`RunStation`] (in
133//! [`CurveRelation::OffsetsAtStations`]) likewise. Defaults are a window
134//! of `0` and `Outgoing`, today's reading. [`Station`],
135//! [`StationedSection`] and [`StationedOpenSection`] have public fields
136//! and stay window-free, so [`SolidOperation::StationedSpine`],
137//! [`SurfaceRelation::SectionedSurface`] and
138//! [`CurveRelation::OffsetByStations`] read as before; their general forms
139//! above carry windows.
140//!
141//! - **Snapping.** Each windowed station is snapped by the #294 rule
142//!   against the run's directrix: onto the one seam whose frame may jump
143//!   within its window, at the seam's own (certified) distance; a window
144//!   holding two seams, or a seam whose certified position straddles its
145//!   edge, is refused by name. The run then uses the snapped distance
146//!   everywhere: the section's place, the linear interpolation between
147//!   stations, the run's span. Snapped distances must still increase
148//!   strictly; two stations snapped onto one seam, or one snapped past its
149//!   neighbour, are refused by name.
150//! - **Side.** The run's first station reads the outgoing piece and its
151//!   last the incoming one, whatever side they name: the run lies on those
152//!   pieces, as a curve's start and end have one piece. An interior
153//!   station on a seam where the tangent turns stands in the mitre plane,
154//!   so its section (or offset point) is the mitre section, placed in BOTH
155//!   pieces' frames and projected: its side is not read. An interior
156//!   station on a seam where the tangent does not turn but the frame may
157//!   jump (a banked curve's roll) is placed in the frame of the piece its
158//!   side names.
159//!
160//! # Run stations at the basis's ends (#303)
161//!
162//! An offset by distances often continues its first and last offsets
163//! unchanged out to its basis's ends (IFC 4.3 ADD2 8.9.3.42.3), so its
164//! run needs a station AT the basis's end. Where the basis's length has
165//! no closed form (an offset beside a gradient curve, a clothoid or a
166//! B-spline, a B-spline itself), a caller without a provider cannot state
167//! that distance. A [`RunStation`] may therefore stand at the basis's
168//! start or end without stating it ([`RunStation::at_basis_start`],
169//! [`RunStation::at_basis_end`]): its [`RunStation::basis_end`] names
170//! which, and its stated distance is `0` and not read.
171//!
172//! - **Resolution.** A resolver reads the start as distance `0`, where the
173//!   station measure starts on every curve, and the end as the basis's
174//!   length by the kernel's own measure of it, the number a curve path's
175//!   open end resolves to (#298): the length of the flattened basis
176//!   (`axiolid-mesh-compile`'s `station::curve_path`) as
177//!   `axiolid-evaluate`'s `CompositeBasis` measures it, bitwise; on an
178//!   unbounded line its parameter domain end, `|direction|`, as inside a
179//!   composite. A station at the end is then the same station stated at
180//!   that number.
181//! - **Order.** The graph refuses, when the node is pushed, a station at
182//!   the start that is not the run's first, one at the end that is not its
183//!   last, and either stating a distance other than `0`; the stated
184//!   distances must increase strictly from a start station's `0`. Whether
185//!   the end comes after the last stated station needs the length, so a
186//!   resolver refuses by name a run whose resolved distances do not
187//!   increase strictly (a stated station at or past the basis's end).
188//! - **Windows.** Snapping (#301) applies after resolution: a station at
189//!   the end with a window snaps onto the one seam within the window of
190//!   the resolved end, by the #294 rule.
191//!
192//! Only the stations of a [`CurveRelation::OffsetsAtStations`] carry it;
193//! a [`SectionAtStation`] states its distance.
194//!
195//! [`SolidOperation::SectionsAtStations`]: crate::SolidOperation::SectionsAtStations
196//! [`SolidOperation::StationedSpine`]: crate::SolidOperation::StationedSpine
197//! [`SurfaceRelation::OpenSectionsAtStations`]: crate::SurfaceRelation::OpenSectionsAtStations
198//! [`SurfaceRelation::SectionedSurface`]: crate::SurfaceRelation::SectionedSurface
199//! [`CurveRelation::OffsetsAtStations`]: crate::CurveRelation::OffsetsAtStations
200//! [`CurveRelation::OffsetByStations`]: crate::CurveRelation::OffsetByStations
201//!
202//! # Placing a node at a station (#264)
203//!
204//! [`InstanceAtStation`] reuses a node -- a curve, a solid, a surface --
205//! in the frame of an [`OrientedCurveStation`], the way [`Instance`]
206//! reuses one under a fixed transform. The station stays symbolic: a
207//! kernel resolves its frame when it evaluates the node, with the
208//! station's [`StationFrame`], orientation and [`SeamSide`]. The source's
209//! local axes map onto the station's oriented frame as a linear placement
210//! reads them (buildingSMART IFC 4.3, `IfcAxis2PlacementLinear`):
211//!
212//! - local `x` onto the oriented tangent (`tangent'`),
213//! - local `y` onto the oriented lateral, to the LEFT (`lateral'`),
214//! - local `z` onto the oriented up (`up' = tangent' x lateral'`),
215//! - the local origin onto the station's point, its offsets read in the
216//!   base frame.
217//!
218//! This is a rigid motion (right-handed, no scale). It is not the profile
219//! mapping above (a section's `x` along lateral and `y` along up), and not
220//! the provider layout a resolved station presents (`x` tangent, `y` up,
221//! `z` right): a placed curve is laid ALONG the curve, a section ACROSS
222//! it. A 2D source curve lies in its local `z = 0`, so it is placed in the
223//! plane of the oriented tangent and lateral, and the placed curve is a 3D
224//! curve whatever the source's dimension.
225//!
226//! [`Instance`]: crate::Instance
227//!
228//! # Sections between stations
229//!
230//! A profile placed at a station maps its `x` onto the lateral axis and its
231//! `y` onto up, so its normal `x x y` is the tangent. Between two stations
232//! everything is interpolated LINEARLY in distance: the offsets, each
233//! section point with its counterpart in the next section, and an explicit
234//! orientation (the unit axis and the unit reference direction,
235//! component-wise in the base frame, an absent one counting as `(0, 0, 1)`
236//! and `(1, 0, 0)`, then orthonormalised as above; a resolver refuses by
237//! name an interpolated pair that degenerates).
238//!
239//! # Matching points between sections
240//!
241//! Untagged sections are matched by position: a closed profile's points by
242//! ring and vertex index, an open section's by vertex index. Tagged
243//! sections ([`SectionAtStation::tags`], [`StationedOpenSection::tags`])
244//! are matched by tag. One run of sections is tagged throughout or not at
245//! all, and every section carries the same SET of tags, none repeated, so
246//! the tags are a bijection between any two sections:
247//!
248//! - an open section's tags run along its polyline in the first section's
249//!   order or in reverse (a reversed section is joined reversed); any other
250//!   order would cross the sheet and is refused;
251//! - a closed section's tags name its contour vertices, the outer ring's
252//!   then each hole's, each ring from its first segment's start in its
253//!   authored sense. Its profile must be polygonal (straight segments only,
254//!   optionally under a `Profile::Derived` transform). The tags must map
255//!   each ring of the first section onto one ring of every other, the
256//!   outer onto the outer, in the same cyclic order once both are wound
257//!   alike (outer counter-clockwise, holes clockwise): a section may start
258//!   a ring at another vertex, list its holes in another order or wind a
259//!   ring the other way, nothing else. A resolver checks this, since it
260//!   needs the profile's vertices.
261
262use axiolid_core::{Scalar, Vec3};
263pub use axiolid_curve::SeamSide;
264
265use crate::NodeId;
266
267/// Offsets of a station from its basis curve, in the curve's section frame.
268#[derive(Debug, Clone, Copy, PartialEq, Default)]
269#[cfg_attr(
270    feature = "serde",
271    derive(serde::Serialize, serde::Deserialize),
272    serde(deny_unknown_fields)
273)]
274pub struct StationOffsets {
275    /// Along the lateral axis, positive to the left of the tangent.
276    pub lateral: Scalar,
277    /// Along the section's up axis.
278    pub vertical: Scalar,
279    /// Along the tangent, positive in the direction of increasing distance.
280    pub longitudinal: Scalar,
281}
282
283impl StationOffsets {
284    /// No offset: the station is on the curve.
285    pub const ZERO: Self = Self::new(0.0, 0.0, 0.0);
286
287    /// Offsets along the lateral, up and tangent axes.
288    #[must_use]
289    pub const fn new(lateral: Scalar, vertical: Scalar, longitudinal: Scalar) -> Self {
290        Self {
291            lateral,
292            vertical,
293            longitudinal,
294        }
295    }
296
297    /// Whether every offset is finite.
298    #[must_use]
299    pub fn is_finite(&self) -> bool {
300        self.lateral.is_finite() && self.vertical.is_finite() && self.longitudinal.is_finite()
301    }
302}
303
304/// A distance along a basis curve with offsets in its section frame.
305#[derive(Debug, Clone, Copy, PartialEq, Default)]
306#[cfg_attr(
307    feature = "serde",
308    derive(serde::Serialize, serde::Deserialize),
309    serde(deny_unknown_fields)
310)]
311pub struct Station {
312    /// Distance from the basis curve's start, in its convention (see the
313    /// [module documentation](self)).
314    pub distance: Scalar,
315    /// Offsets in the section frame at that distance.
316    pub offsets: StationOffsets,
317}
318
319impl Station {
320    /// A station at `distance` with `offsets`.
321    #[must_use]
322    pub const fn new(distance: Scalar, offsets: StationOffsets) -> Self {
323        Self { distance, offsets }
324    }
325
326    /// A station on the curve at `distance`.
327    #[must_use]
328    pub const fn at(distance: Scalar) -> Self {
329        Self::new(distance, StationOffsets::ZERO)
330    }
331
332    /// Whether the distance is finite and not negative and every offset is
333    /// finite.
334    #[must_use]
335    pub fn is_well_formed(&self) -> bool {
336        self.distance.is_finite() && self.distance >= 0.0 && self.offsets.is_finite()
337    }
338}
339
340/// Which frame a station's offsets and orientation are read in.
341#[non_exhaustive]
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
343#[cfg_attr(
344    feature = "serde",
345    derive(serde::Serialize, serde::Deserialize),
346    serde(deny_unknown_fields)
347)]
348pub enum StationFrame {
349    /// The basis curve's section frame: rolled on a banked curve, leaning
350    /// with the grade on any 3D curve.
351    #[default]
352    Section,
353    /// The vertical frame: the tangent's horizontal projection, the
354    /// horizontal left normal and `+Z`. Grade and bank are dropped, so a
355    /// section stands upright.
356    Plan,
357}
358
359/// A point and frame at a station along a basis curve.
360///
361/// Resolving it is curve evaluation, which a kernel does; see the
362/// [module documentation](self) for the conventions.
363#[derive(Debug, Clone, Copy, PartialEq)]
364#[cfg_attr(
365    feature = "serde",
366    derive(serde::Serialize, serde::Deserialize),
367    serde(deny_unknown_fields)
368)]
369pub struct CurveStation {
370    /// Basis curve, 2D or 3D.
371    pub basis: NodeId,
372    /// Distance and offsets.
373    pub station: Station,
374    /// Frame the offsets are read in, and that the station presents.
375    pub frame: StationFrame,
376}
377
378impl CurveStation {
379    /// A station along `basis` in the basis curve's section frame.
380    #[must_use]
381    pub const fn new(basis: NodeId, station: Station) -> Self {
382        Self {
383            basis,
384            station,
385            frame: StationFrame::Section,
386        }
387    }
388
389    /// This station, unturned, reading `side` of a seam it lies on (#263).
390    ///
391    /// A plain curve station always reads the outgoing piece; the side is
392    /// carried by an [`OrientedCurveStation`] whose orientation is the base
393    /// frame, so the node to push is
394    /// [`GeometryNode::OrientedCurveStation`](crate::GeometryNode::OrientedCurveStation).
395    #[must_use]
396    pub const fn with_seam_side(self, side: SeamSide) -> OrientedCurveStation {
397        OrientedCurveStation::new(self, StationOrientation::new(None, None)).with_seam_side(side)
398    }
399}
400
401/// A closed profile standing at a station along a sectioned spine's
402/// directrix.
403#[derive(Debug, Clone, Copy, PartialEq)]
404#[cfg_attr(
405    feature = "serde",
406    derive(serde::Serialize, serde::Deserialize),
407    serde(deny_unknown_fields)
408)]
409pub struct StationedSection {
410    /// Area profile node.
411    pub profile: NodeId,
412    /// Distance along the directrix and offsets of the profile's origin.
413    pub station: Station,
414}
415
416/// An open section standing at a station along a sectioned surface's
417/// directrix, its points named by tags.
418///
419/// `tags` names the vertices of the section's polyline in order; two
420/// consecutive sections are joined point to point by equal tags. Every
421/// section of one surface carries the same tags, in the first section's
422/// order or in reverse, or none (then the vertices are joined by index);
423/// see the [module documentation](self).
424#[derive(Debug, Clone, PartialEq)]
425#[cfg_attr(
426    feature = "serde",
427    derive(serde::Serialize, serde::Deserialize),
428    serde(deny_unknown_fields)
429)]
430pub struct StationedOpenSection {
431    /// Open profile node.
432    pub profile: NodeId,
433    /// One tag per vertex, or empty.
434    pub tags: Vec<String>,
435    /// Distance along the directrix and offsets of the profile's origin.
436    pub station: Station,
437}
438
439/// Largest sine of the angle between a [`StationOrientation`]'s axis and
440/// reference direction that still counts as parallel.
441pub const ORIENTATION_TOLERANCE: Scalar = 1e-9;
442
443/// An explicit in-section orientation at a station (#246).
444///
445/// Both vectors are components in the station's base frame, `(tangent,
446/// lateral, up)`; see the [module documentation](self) for their meaning,
447/// defaults and orthonormalisation. Both absent is the base frame itself,
448/// the [`Default`].
449#[non_exhaustive]
450#[derive(Debug, Clone, Copy, PartialEq, Default)]
451#[cfg_attr(
452    feature = "serde",
453    derive(serde::Serialize, serde::Deserialize),
454    serde(deny_unknown_fields)
455)]
456pub struct StationOrientation {
457    /// The oriented frame's up (local `Z`), exact; `None` for `(0, 0, 1)`.
458    pub axis: Option<Vec3>,
459    /// The direction the oriented frame's tangent role (local `X`) is
460    /// taken from, made perpendicular to the axis; `None` for `(1, 0, 0)`.
461    pub ref_direction: Option<Vec3>,
462}
463
464impl StationOrientation {
465    /// An orientation from an optional axis and reference direction, both
466    /// in the base frame.
467    #[must_use]
468    pub const fn new(axis: Option<Vec3>, ref_direction: Option<Vec3>) -> Self {
469        Self {
470            axis,
471            ref_direction,
472        }
473    }
474
475    /// Whether neither vector is given, so the base frame is used as is.
476    #[must_use]
477    pub const fn is_base(&self) -> bool {
478        self.axis.is_none() && self.ref_direction.is_none()
479    }
480
481    /// The unit axis and unit reference direction, defaults filled in.
482    ///
483    /// # Errors
484    ///
485    /// Why there are none, worded for
486    /// [`GraphError::InvalidStation`](crate::GraphError::InvalidStation):
487    /// a zero or non-finite vector, or the two parallel or anti-parallel
488    /// within [`ORIENTATION_TOLERANCE`].
489    pub fn unit_axes(&self) -> Result<(Vec3, Vec3), &'static str> {
490        let unit = |vector: Vec3, what: &'static str| {
491            let length = vector.length();
492            if vector.is_finite() && length.is_finite() && length > 1e-12 {
493                Ok(vector / length)
494            } else {
495                Err(what)
496            }
497        };
498        let axis = unit(
499            self.axis.unwrap_or(Vec3::Z),
500            "the orientation's axis is zero or not finite",
501        )?;
502        let reference = unit(
503            self.ref_direction.unwrap_or(Vec3::X),
504            "the orientation's reference direction is zero or not finite",
505        )?;
506        if axis.cross(reference).length() <= ORIENTATION_TOLERANCE {
507            return Err("the orientation's axis and reference direction are parallel");
508        }
509        Ok((axis, reference))
510    }
511}
512
513/// A [`CurveStation`] with an explicit orientation (#246) and the side of
514/// a seam it reads (#263).
515///
516/// Its point is the station's, offsets read in the base frame; its frame
517/// is the base frame turned by `orientation` (see the
518/// [module documentation](self)). On a seam of the basis curve the base
519/// frame is the one of the piece `seam` names.
520#[non_exhaustive]
521#[derive(Debug, Clone, Copy, PartialEq)]
522#[cfg_attr(
523    feature = "serde",
524    derive(serde::Serialize, serde::Deserialize),
525    serde(deny_unknown_fields)
526)]
527pub struct OrientedCurveStation {
528    /// The basis curve, distance, offsets and base frame.
529    pub station: CurveStation,
530    /// The axis and reference direction, in the base frame.
531    pub orientation: StationOrientation,
532    /// The piece read when the station lies on a seam of its basis curve;
533    /// [`SeamSide::Outgoing`] unless set by [`Self::with_seam_side`].
534    pub seam: SeamSide,
535    /// The seam-snapping window (#294): a station within this distance of
536    /// a seam of its basis is read ON that seam, from the side `seam`
537    /// names. `0` unless set by [`Self::with_seam_window`], which is the
538    /// arc-length tolerance alone (see the
539    /// [module documentation](self#seam-snapping-window-294)).
540    ///
541    /// Format 1.1 of the graph's wire format (ADR 0085): omitted when it
542    /// is `+0.0`, and an absent window reads as `0`.
543    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "no_window"))]
544    pub seam_window: Scalar,
545}
546
547/// Whether a seam window is the default `+0.0`, which the wire format
548/// omits (ADR 0085, format 1.1): a graph without windows writes the 1.0
549/// graph.
550#[cfg(feature = "serde")]
551// serde's `skip_serializing_if` passes the field by reference.
552#[allow(clippy::trivially_copy_pass_by_ref)]
553fn no_window(window: &Scalar) -> bool {
554    window.to_bits() == 0
555}
556
557/// Whether a run station's seam side is the default `Outgoing`, which the
558/// wire format omits (ADR 0085, format 1.2): a run without sides writes
559/// the 1.1 graph.
560#[cfg(feature = "serde")]
561// serde's `skip_serializing_if` passes the field by reference.
562#[allow(clippy::trivially_copy_pass_by_ref)]
563fn outgoing(side: &SeamSide) -> bool {
564    *side == SeamSide::Outgoing
565}
566
567impl OrientedCurveStation {
568    /// `station` turned by `orientation`, reading the outgoing piece on a
569    /// seam.
570    #[must_use]
571    pub const fn new(station: CurveStation, orientation: StationOrientation) -> Self {
572        Self {
573            station,
574            orientation,
575            seam: SeamSide::Outgoing,
576            seam_window: 0.0,
577        }
578    }
579
580    /// The same station reading `side` of a seam it lies on.
581    #[must_use]
582    pub const fn with_seam_side(mut self, side: SeamSide) -> Self {
583        self.seam = side;
584        self
585    }
586
587    /// The same station snapping onto a seam of its basis within `window`
588    /// (#294), in the basis's station measure: a station that close to
589    /// one seam is read on it, from its [`SeamSide`]. `0` is no window
590    /// beyond the arc-length tolerance. A negative or non-finite window is
591    /// refused when the node is pushed (see the
592    /// [module documentation](self#seam-snapping-window-294)).
593    #[must_use]
594    pub const fn with_seam_window(mut self, window: Scalar) -> Self {
595        self.seam_window = window;
596        self
597    }
598}
599
600impl From<CurveStation> for OrientedCurveStation {
601    /// The station in its base frame, reading the outgoing piece on a seam:
602    /// what the plain station means.
603    fn from(station: CurveStation) -> Self {
604        Self::new(station, StationOrientation::default())
605    }
606}
607
608/// A section standing at a station, with optional tags and an optional
609/// explicit orientation (#246): the general form of [`StationedSection`]
610/// (closed) and [`StationedOpenSection`] (open).
611///
612/// Built with [`SectionAtStation::new`] and the `with_` methods, so that
613/// fields can be added without breaking callers.
614#[non_exhaustive]
615#[derive(Debug, Clone, PartialEq)]
616#[cfg_attr(
617    feature = "serde",
618    derive(serde::Serialize, serde::Deserialize),
619    serde(deny_unknown_fields)
620)]
621pub struct SectionAtStation {
622    /// The profile node: an area profile in a sectioned spine, an open
623    /// profile in a sectioned surface.
624    pub profile: NodeId,
625    /// Distance along the directrix and offsets of the profile's origin,
626    /// in the base frame.
627    pub station: Station,
628    /// One tag per vertex, or empty to match by index; see the
629    /// [module documentation](self).
630    pub tags: Vec<String>,
631    /// The section's orientation in the base frame; the default is the
632    /// base frame.
633    pub orientation: StationOrientation,
634    /// The piece read where the section stands on a seam its run does not
635    /// decide (#301): [`SeamSide::Outgoing`] unless set by
636    /// [`Self::with_seam_side`]. See the
637    /// [module documentation](self#seam-windows-on-run-stations-301) for
638    /// where a run reads it.
639    ///
640    /// Format 1.2 of the graph's wire format (ADR 0085): omitted when it
641    /// is `Outgoing`, and an absent side reads as `Outgoing`.
642    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "outgoing"))]
643    pub seam: SeamSide,
644    /// The seam-snapping window (#294, #301): a section within this
645    /// distance of one seam of the directrix stands ON that seam. `0`
646    /// unless set by [`Self::with_seam_window`], which is the arc-length
647    /// tolerance alone.
648    ///
649    /// Format 1.2 of the graph's wire format (ADR 0085): omitted when it
650    /// is `+0.0`, and an absent window reads as `0`.
651    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "no_window"))]
652    pub seam_window: Scalar,
653}
654
655impl SectionAtStation {
656    /// `profile` at `station`, untagged, in the base frame.
657    #[must_use]
658    pub const fn new(profile: NodeId, station: Station) -> Self {
659        Self {
660            profile,
661            station,
662            tags: Vec::new(),
663            orientation: StationOrientation::new(None, None),
664            seam: SeamSide::Outgoing,
665            seam_window: 0.0,
666        }
667    }
668
669    /// The same section with `tags`, one per vertex.
670    #[must_use]
671    pub fn with_tags<I, T>(mut self, tags: I) -> Self
672    where
673        I: IntoIterator<Item = T>,
674        T: Into<String>,
675    {
676        self.tags = tags.into_iter().map(Into::into).collect();
677        self
678    }
679
680    /// The same section turned by `orientation`.
681    #[must_use]
682    pub const fn with_orientation(mut self, orientation: StationOrientation) -> Self {
683        self.orientation = orientation;
684        self
685    }
686
687    /// The same section reading `side` of a seam it stands on, where its
688    /// run does not decide the side (#301; see the
689    /// [module documentation](self#seam-windows-on-run-stations-301)).
690    #[must_use]
691    pub const fn with_seam_side(mut self, side: SeamSide) -> Self {
692        self.seam = side;
693        self
694    }
695
696    /// The same section snapping onto a seam of the directrix within
697    /// `window` (#301), in the directrix's station measure, by the #294
698    /// rule. `0` is no window beyond the arc-length tolerance; a negative
699    /// or non-finite window is refused when the node is pushed.
700    #[must_use]
701    pub const fn with_seam_window(mut self, window: Scalar) -> Self {
702        self.seam_window = window;
703        self
704    }
705}
706
707impl From<StationedSection> for SectionAtStation {
708    fn from(section: StationedSection) -> Self {
709        Self::new(section.profile, section.station)
710    }
711}
712
713impl From<StationedOpenSection> for SectionAtStation {
714    fn from(section: StationedOpenSection) -> Self {
715        Self::new(section.profile, section.station).with_tags(section.tags)
716    }
717}
718
719/// Which end of its basis a [`RunStation`] stands at, measured by the
720/// kernel (#303; see the
721/// [module documentation](self#run-stations-at-the-basiss-ends-303)).
722#[non_exhaustive]
723#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
724#[cfg_attr(
725    feature = "serde",
726    derive(serde::Serialize, serde::Deserialize),
727    serde(deny_unknown_fields)
728)]
729pub enum BasisEnd {
730    /// The basis's start: distance `0`.
731    Start,
732    /// The basis's end: its length, by the kernel's own measure.
733    End,
734}
735
736/// A station of an offset run with the side of a seam it reads and its
737/// seam-snapping window (#301): the general form of the plain [`Station`]
738/// a [`CurveRelation::OffsetByStations`] takes, used by
739/// [`CurveRelation::OffsetsAtStations`]. It may also stand at its basis's
740/// start or end, its distance measured by the kernel (#303).
741///
742/// Built with [`RunStation::new`], [`RunStation::at_basis_start`],
743/// [`RunStation::at_basis_end`] and the `with_` methods, so that fields can
744/// be added without breaking callers. See the
745/// [module documentation](self#seam-windows-on-run-stations-301) for how a
746/// run reads the side and the window, and
747/// [its #303 section](self#run-stations-at-the-basiss-ends-303) for the
748/// basis's ends.
749///
750/// [`CurveRelation::OffsetByStations`]: crate::CurveRelation::OffsetByStations
751/// [`CurveRelation::OffsetsAtStations`]: crate::CurveRelation::OffsetsAtStations
752#[non_exhaustive]
753#[derive(Debug, Clone, Copy, PartialEq)]
754#[cfg_attr(
755    feature = "serde",
756    derive(serde::Serialize, serde::Deserialize),
757    serde(deny_unknown_fields)
758)]
759pub struct RunStation {
760    /// Distance along the basis and offsets in its frame there.
761    pub station: Station,
762    /// The piece read where the station stands on a seam its run does not
763    /// decide: [`SeamSide::Outgoing`] unless set by
764    /// [`Self::with_seam_side`]. Omitted from the wire format when it is
765    /// `Outgoing`.
766    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "outgoing"))]
767    pub seam: SeamSide,
768    /// The seam-snapping window (#294): a station within this distance of
769    /// one seam of the basis stands ON it. `0` unless set by
770    /// [`Self::with_seam_window`]. Omitted from the wire format when it is
771    /// `+0.0`.
772    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "no_window"))]
773    pub seam_window: Scalar,
774    /// The end of the basis the station stands at, its distance measured
775    /// by the kernel (#303): `None`, the default, for the stated
776    /// `station.distance`; otherwise that distance is `0` and not read
777    /// (see the
778    /// [module documentation](self#run-stations-at-the-basiss-ends-303)).
779    ///
780    /// Format 1.3 of the graph's wire format (ADR 0085): omitted when it
781    /// is `None`, and an absent end reads as `None`.
782    #[cfg_attr(
783        feature = "serde",
784        serde(default, skip_serializing_if = "Option::is_none")
785    )]
786    pub basis_end: Option<BasisEnd>,
787}
788
789impl RunStation {
790    /// `station`, reading the outgoing piece on a seam, with no window.
791    #[must_use]
792    pub const fn new(station: Station) -> Self {
793        Self {
794            station,
795            seam: SeamSide::Outgoing,
796            seam_window: 0.0,
797            basis_end: None,
798        }
799    }
800
801    /// A station at the basis's start with `offsets` (#303): distance `0`,
802    /// reading the outgoing piece, with no window.
803    #[must_use]
804    pub const fn at_basis_start(offsets: StationOffsets) -> Self {
805        let mut station = Self::new(Station::new(0.0, offsets));
806        station.basis_end = Some(BasisEnd::Start);
807        station
808    }
809
810    /// A station at the basis's end with `offsets` (#303), reading the
811    /// outgoing piece, with no window: its distance is the basis's length
812    /// by the kernel's own measure, which the caller need not know (see
813    /// the [module documentation](self#run-stations-at-the-basiss-ends-303)).
814    #[must_use]
815    pub const fn at_basis_end(offsets: StationOffsets) -> Self {
816        let mut station = Self::new(Station::new(0.0, offsets));
817        station.basis_end = Some(BasisEnd::End);
818        station
819    }
820
821    /// The same station reading `side` of a seam it stands on, where its
822    /// run does not decide the side.
823    #[must_use]
824    pub const fn with_seam_side(mut self, side: SeamSide) -> Self {
825        self.seam = side;
826        self
827    }
828
829    /// The same station snapping onto a seam of its basis within `window`,
830    /// in the basis's station measure, by the #294 rule. A negative or
831    /// non-finite window is refused when the node is pushed.
832    #[must_use]
833    pub const fn with_seam_window(mut self, window: Scalar) -> Self {
834        self.seam_window = window;
835        self
836    }
837}
838
839impl From<Station> for RunStation {
840    /// The plain station: the outgoing piece, no window, what a station of
841    /// an [`OffsetByStations`](crate::CurveRelation::OffsetByStations)
842    /// means.
843    fn from(station: Station) -> Self {
844        Self::new(station)
845    }
846}
847
848/// A node reused in the frame of an [`OrientedCurveStation`] (#264): the
849/// station-framed form of [`Instance`](crate::Instance).
850///
851/// The source's local `x`, `y`, `z` and origin are placed on the
852/// station's oriented tangent, left lateral, up and point (see the
853/// [module documentation](self#placing-a-node-at-a-station-264)); the
854/// frame is resolved at evaluation, reading the side of a seam the station
855/// names. A placed curve is a 3D curve, a placed solid or surface keeps
856/// its family. Built with [`InstanceAtStation::new`], so fields can be
857/// added without breaking callers.
858#[non_exhaustive]
859#[derive(Debug, Clone, Copy, PartialEq)]
860#[cfg_attr(
861    feature = "serde",
862    derive(serde::Serialize, serde::Deserialize),
863    serde(deny_unknown_fields)
864)]
865pub struct InstanceAtStation {
866    /// The reused node, in its own local coordinates.
867    pub source: NodeId,
868    /// The station whose oriented frame places it.
869    pub station: OrientedCurveStation,
870}
871
872impl InstanceAtStation {
873    /// `source` placed in the frame of `station`.
874    #[must_use]
875    pub const fn new(source: NodeId, station: OrientedCurveStation) -> Self {
876        Self { source, station }
877    }
878}