Skip to main content

brep_kernel/construction/sweep_topology/
path.rs

1use super::*;
2
3/// The largest tangent break, in radians, a chained path may have at a joint
4/// before the joint is a CORNER.
5///
6/// This is a REAL geometric boundary, not a tuning knob, and it now selects the
7/// LANE rather than deciding a refusal. The skinning builder lofts consecutive
8/// stations, so a corner between two stations would be rendered as a smooth
9/// blend across it — the tube cuts the corner, and near a sharp one the section
10/// sweeps through itself. So a break inside this band is skinned as ONE tube
11/// (line→fillet→line, spline pieces, an arc train), and a break past it is a
12/// corner, which is built a different way or not at all:
13///   - every segment STRAIGHT (a polyline, open or CLOSED) → the MITRE lane: the
14///     section is swept along each segment and the two sweeps are trimmed to
15///     the joint's bisector plane, sharing one face loop there
16///     ([`Self::cornered_polyline`], `sweep_topology/miter.rs`). A closed one is
17///     a picture FRAME: its closing joint is a mitre like the rest and it has no
18///     caps at all;
19///   - a corner at a CURVED segment → refused by name. A curve has no single
20///     direction for a bisector plane to bisect, so there is no plane both
21///     sides can be trimmed to.
22///
23/// ~1.15° (0.02 rad). Loose enough to absorb the tangent disagreement of two
24/// curves a sketch chained on endpoint coincidence, tight enough that anything
25/// a user would call a corner is refused rather than silently rounded off.
26///
27/// This is INDEPENDENT of the chainer's join tolerance, and deliberately so.
28/// `common::chain_path_segments` joins on POSITION (`1e-5 * scale`); this gate
29/// measures DIRECTION. Two collinear segments meeting with a positional gap have
30/// a zero tangent break, and two segments meeting exactly at a point can still
31/// break 90°. So a run the chainer happily orders head-to-tail may still be
32/// refused by a consumer of this classification — which is not an inconsistency
33/// but the SW/SWP split itself: the chainer's job is to decide what order the
34/// picks form a run in, and this gate's job is to decide whether that run is one
35/// the skinning builder can carry a section along.
36pub const MAX_JOINT_TANGENT_BREAK: f64 = 0.02;
37
38/// How much the CURVATURE VECTOR may disagree across a joint, relative to the
39/// curvatures being compared, before the joint is G1 rather than G2.
40///
41/// Dimensionless and deliberately tiny: the two sides of a G2 joint are the same
42/// analytic curve evaluated from two parameterizations (two arcs of one circle,
43/// two collinear lines), so they agree to rounding — a dozen orders of magnitude
44/// inside this bar. Everything else separates by a FULL curvature: a line
45/// meeting a fillet of radius `r` jumps by `1/r`, an S-bend of two equal arcs by
46/// `2/r`. There is no population in between to tune against, which is why this
47/// number needs no tuning.
48const MAX_JOINT_CURVATURE_BREAK: f64 = 1e-9;
49
50/// How far a sampled path point may sit off its own fitted plane, relative to
51/// the path's extent, before the path is SPATIAL rather than planar.
52///
53/// Read only by the CLOSED lane, and the reason it is tight: a planar closed
54/// path transports a section round the loop and back to itself exactly (the
55/// frame's only rotation is about the plane normal, which comes back to where it
56/// started), while a spatial one returns the section rotated by the path's
57/// HOLONOMY. Calling a spatial path planar would mean skinning that mismatch
58/// into the ring instead of refusing it, so the bar admits only paths that are
59/// planar to rounding — a sketch-authored chain, or resident edges that share a
60/// plane — and refuses the rest by name.
61const MAX_PLANARITY_DEVIATION: f64 = 1e-7;
62
63/// How continuous a path is AT ONE JOINT — the classification every sweep
64/// refusal reads instead of re-deriving the same angle from the curves.
65///
66/// The three levels are the standard geometric ones, and each is decided by a
67/// measurement [`PathJoint`] carries beside it:
68///   - `G0` — the position matches and nothing else: the tangent turns by more
69///     than [`MAX_JOINT_TANGENT_BREAK`]. A corner.
70///   - `G1` — the unit tangents agree; the curvature VECTORS do not. A line
71///     meeting a fillet arc, two arcs of different radius, an S-bend of two
72///     equal arcs curving opposite ways.
73///   - `G2` — the unit tangents AND the curvature vectors agree. Two arcs of ONE
74///     circle (the shape a sketch publishes for a circle), two collinear lines.
75///
76/// G1 and G2 are not distinguished by any refusal today — the skinning builder
77/// needs G1 and is indifferent to the curvature jump, because it interpolates
78/// the stations rather than the segments. The distinction is recorded because it
79/// is the difference between a path whose swept surface is curvature-continuous
80/// and one whose is not, which is what a later curvature-driven station law
81/// (remaining work item 2) has to refine against.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum JointContinuity {
84    /// Position only — a corner.
85    G0,
86    /// Tangent continuous, curvature discontinuous.
87    G1,
88    /// Tangent and curvature continuous.
89    G2,
90}
91
92impl JointContinuity {
93    /// The `G0` / `G1` / `G2` spelling, for a message or a record.
94    pub fn label(self) -> &'static str {
95        match self {
96            JointContinuity::G0 => "G0",
97            JointContinuity::G1 => "G1",
98            JointContinuity::G2 => "G2",
99        }
100    }
101}
102
103/// ONE joint of a resolved path: which two segments meet there, how continuously
104/// ([`JointContinuity`]), and the two measurements that decided it.
105///
106/// `before`/`after` are INDICES into the path's segments, and the CLOSING joint
107/// of a closed path is `(n-1, 0)` — including the single-segment case `(0, 0)`,
108/// a lone closed curve meeting itself.
109#[derive(Debug, Clone, Copy)]
110pub struct PathJoint {
111    /// The segment ARRIVING at the joint (at its own domain end).
112    pub before: usize,
113    /// The segment DEPARTING from it (at its own domain start).
114    pub after: usize,
115    /// The angle between the arriving and departing UNIT TANGENTS, in radians.
116    /// The measured angle a corner refusal quotes.
117    pub tangent_break: f64,
118    /// `|Δk|` of the CURVATURE VECTOR across the joint, in 1/length. A vector
119    /// rather than a magnitude, so two equal arcs curving opposite ways (an
120    /// S-bend) read `2/r` rather than zero.
121    pub curvature_break: f64,
122    /// The level those two measurements put the joint at.
123    pub continuity: JointContinuity,
124    /// True for the joint that CLOSES a closed path (the last segment's end
125    /// meeting the first segment's start). An open path has none.
126    pub closing: bool,
127}
128
129/// A resolved sweep path AND its classification: the ordered, head-to-tail
130/// curves, the name of each, whether the run is CLOSED, the PLANE it lies in if
131/// it lies in one, and the continuity of every joint — the closing one included.
132///
133/// This is the sweep's path type, and the classification is computed ONCE, here,
134/// where the path is resolved. Before it, three refusals each derived their own
135/// answer to the same question from the same curves: the builder's corner gate
136/// measured a tangent break while sampling, the sampler decided a path was
137/// closed by comparing its first and last station, and `SW`'s `translate` mode
138/// inferred closure from the fact that a closed path's advances through the
139/// profile must sum to zero. They agreed, but nothing made them agree, and none
140/// of the three could SAY what it had found — a closed path was only ever
141/// reported as the consequence the refusing rule happened to notice.
142///
143/// ORDER AND ORIENTATION are the caller's: this classifies the chain it is
144/// given, segment order and each segment's direction untouched, so the first
145/// segment still decides which way the sweep runs.
146#[derive(Debug, Clone)]
147pub struct SweepPath {
148    /// The ordered, head-to-tail world curves.
149    pub curves: Vec<NurbsCurve>,
150    /// Each curve's source name, parallel to `curves`. A short or empty slice is
151    /// legal — [`SweepPath::name`] degrades to `<unnamed>` — because a caller
152    /// that builds a chain itself (the tube's bend runs, the wire harness'
153    /// spline spans) has no per-segment user-facing name to give.
154    pub names: Vec<String>,
155    /// The last segment's end returns to the first segment's start.
156    pub closed: bool,
157    /// The UNIT NORMAL of the plane the whole path lies in, when it lies in one
158    /// to within [`MAX_PLANARITY_DEVIATION`] of its own extent.
159    ///
160    /// `None` for a spatial path, and also for one whose sampled polygon
161    /// encloses no signed area to derive a normal from — a straight run, or a
162    /// figure-of-eight whose lobes cancel. Only the CLOSED lane reads this, and
163    /// a straight run is never closed.
164    pub planar: Option<Vec3>,
165    /// Every joint, in order: the interior ones `(0,1), (1,2), …`, then the
166    /// CLOSING one `(n-1, 0)` when the path is closed.
167    pub joints: Vec<PathJoint>,
168    /// Every segment is a STRAIGHT LINE — a degree-1 curve with two control
169    /// points, which is the test `SW`'s `translate` mode applies to a segment
170    /// before it reduces it to a chord.
171    ///
172    /// Read with [`Self::corner`] by the MITRE lane: a cornered run of straight
173    /// segments is a polyline, and a polyline corner is the one corner a sweep
174    /// can join exactly (see [`Self::cornered_polyline`]). A corner at a CURVED
175    /// segment is not — there is no single bisector plane a curve's section can
176    /// be trimmed to — so the straightness of the segments, not the size of the
177    /// break, is what separates the two.
178    pub straight: bool,
179    /// Each segment's SCREW AXIS direction, parallel to `curves`: `Some` for a
180    /// segment its builder KNOWS is a helix about that axis, `None` for every
181    /// other curve. Unit length; its sign is irrelevant.
182    ///
183    /// Not measured here, and deliberately so: a helix's published curve is a
184    /// cubic FIT, and a fit is not a helix to any bar a classification could
185    /// hold it to without a tolerance keyed to the fit density — and a TAPERED
186    /// helix is not a constant-slope curve at all. The feature that wound the
187    /// helix hands its axis over instead ([`Self::with_screw_axes`]).
188    ///
189    /// Read only by the `Rigid` placement (SW `pathAlign`): on a helix the path's
190    /// own motion is the SCREW about this axis, and the rotation-minimizing frame
191    /// every other curve is carried by rolls away from that screw by the helix's
192    /// integrated torsion — 169.75° over three turns of a radius-5, pitch-5 coil.
193    pub screw_axes: Vec<Option<Vec3>>,
194}
195
196impl SweepPath {
197    /// Classify `curves` (ordered head-to-tail) into a path. `names` may be
198    /// shorter than `curves`, or empty.
199    pub fn new(curves: Vec<NurbsCurve>, names: Vec<String>) -> Result<Self, String> {
200        if curves.is_empty() {
201            return Err("sweepSolid: path chain is empty".into());
202        }
203        // --- Endpoints, and the SCALE every position test below is relative to.
204        //     Same form as the chainer's (`1e-5 * scale`, floored at 1.0 so a
205        //     path at the origin gets an absolute band): the two have to agree
206        //     about closure, since the chainer STOPS extending a run when its
207        //     tail returns to its head and this is what then reports that the
208        //     run it produced is a ring.
209        let mut endpoints = Vec::with_capacity(curves.len());
210        for curve in &curves {
211            let [t0, t1] = curve.domain()?;
212            endpoints.push((curve.evaluate(t0)?, curve.evaluate(t1)?));
213        }
214        let first_start = endpoints[0].0;
215        let scale = endpoints
216            .iter()
217            .flat_map(|(a, b)| [a, b])
218            .map(|point| point.sub(first_start).length())
219            .fold(1.0_f64, f64::max);
220        let join_tolerance = 1e-5 * scale;
221
222        // --- Sample the run once, for the planarity fit and for the travelled
223        //     distance the closure test needs a floor from. 16 per curve is the
224        //     sampling density `profile_anchor` uses to judge a profile's own
225        //     plane, so a path and a profile are held to the same resolution.
226        let mut samples = Vec::with_capacity(16 * curves.len() + 1);
227        for curve in &curves {
228            let [t0, t1] = curve.domain()?;
229            for index in 0..16 {
230                samples.push(curve.evaluate(t0 + (t1 - t0) * index as f64 / 16.0)?);
231            }
232        }
233        samples.push(endpoints[curves.len() - 1].1);
234        let travel: f64 = samples
235            .windows(2)
236            .map(|pair| pair[1].sub(pair[0]).length())
237            .sum();
238
239        // --- CLOSURE. The run returns to where it started, and has actually
240        //     gone somewhere: a zero-length curve trivially ends where it began,
241        //     and calling that a ring would send it into the closed lane instead
242        //     of the zero-length refusal that names it.
243        let closed = travel > join_tolerance
244            && endpoints[curves.len() - 1]
245                .1
246                .sub(first_start)
247                .length()
248                <= join_tolerance;
249
250        // --- PLANARITY. Newell over the sampled polygon gives the candidate
251        //     normal (exact for a planar sample set, whatever the winding), and
252        //     the samples are then CHECKED against it — a normal that fits
253        //     nothing is no answer. A degenerate normal (no enclosed area) is
254        //     `None` rather than an error: an open straight run is a perfectly
255        //     good path that simply has no one plane.
256        let planar = match crate::polygon::newell_normal(&samples).normalized() {
257            Ok(normal) => {
258                let deviation = samples
259                    .iter()
260                    .map(|point| point.sub(first_start).dot(normal).abs())
261                    .fold(0.0_f64, f64::max);
262                (deviation <= MAX_PLANARITY_DEVIATION * scale).then_some(normal)
263            }
264            Err(_) => None,
265        };
266
267        // --- JOINTS, interior then closing. A closed single-curve path has
268        //     exactly one joint — the curve meeting itself.
269        let mut joints = Vec::with_capacity(curves.len());
270        for index in 1..curves.len() {
271            joints.push(Self::joint(&curves, index - 1, index, false)?);
272        }
273        if closed {
274            joints.push(Self::joint(&curves, curves.len() - 1, 0, true)?);
275        }
276
277        // --- STRAIGHTNESS. The same test `SW`'s `translate` loop applies before
278        //     it reduces a segment to its chord: a degree-1 curve with two
279        //     control points. Recorded per PATH rather than per segment because
280        //     every consumer asks about the whole run — the mitre lane needs all
281        //     of them straight, and one curved segment is what sends a cornered
282        //     run back to the refusal.
283        let straight = curves
284            .iter()
285            .all(|curve| curve.degree == 1 && curve.control_points.len() == 2);
286
287        let screw_axes = vec![None; curves.len()];
288        Ok(Self {
289            curves,
290            names,
291            closed,
292            planar,
293            joints,
294            straight,
295            screw_axes,
296        })
297    }
298
299    /// Attach each segment's SCREW AXIS ([`Self::screw_axes`]), parallel to the
300    /// curves. A zero axis is refused rather than silently read as "no axis".
301    pub fn with_screw_axes(mut self, axes: Vec<Option<Vec3>>) -> Result<Self, String> {
302        if axes.len() != self.curves.len() {
303            return Err(format!(
304                "sweepSolid: {} screw axes for a {}-segment path",
305                axes.len(),
306                self.curves.len()
307            ));
308        }
309        self.screw_axes = axes
310            .into_iter()
311            .enumerate()
312            .map(|(index, axis)| {
313                axis.map(|axis| {
314                    axis.normalized().map_err(|_| {
315                        format!(
316                            "sweepSolid: path segment '{}' has a degenerate screw axis",
317                            self.name(index)
318                        )
319                    })
320                })
321                .transpose()
322            })
323            .collect::<Result<_, _>>()?;
324        Ok(self)
325    }
326
327    /// Classify a borrowed chain — the entry a caller assembling curves for one
328    /// build uses ([`Self::new`] takes them by value for a caller that owns them).
329    pub fn from_curves(curves: &[NurbsCurve], names: &[String]) -> Result<Self, String> {
330        Self::new(curves.to_vec(), names.to_vec())
331    }
332
333    /// One joint's measurements and level. `before` is read at its domain END and
334    /// `after` at its domain START — the tangent the path ARRIVES with against
335    /// the one it DEPARTS with, which is the only pair that measures the joint
336    /// itself: sampling a station further along either side would fold that
337    /// segment's own curvature into the break and let a genuine corner through
338    /// on a curved segment.
339    fn joint(
340        curves: &[NurbsCurve],
341        before: usize,
342        after: usize,
343        closing: bool,
344    ) -> Result<PathJoint, String> {
345        let arriving = Self::frame_at(&curves[before], true, before, "end")?;
346        let departing = Self::frame_at(&curves[after], false, after, "start")?;
347        // Both unit, so the dot is the cosine of the break.
348        let tangent_break = arriving.0.dot(departing.0).clamp(-1.0, 1.0).acos();
349        let curvature_break = departing.1.sub(arriving.1).length();
350        let curvature_bar = MAX_JOINT_CURVATURE_BREAK
351            * (1.0 + arriving.1.length() + departing.1.length());
352        let continuity = if tangent_break > MAX_JOINT_TANGENT_BREAK {
353            JointContinuity::G0
354        } else if curvature_break > curvature_bar {
355            JointContinuity::G1
356        } else {
357            JointContinuity::G2
358        };
359        Ok(PathJoint {
360            before,
361            after,
362            tangent_break,
363            curvature_break,
364            continuity,
365            closing,
366        })
367    }
368
369    /// `(unit tangent, curvature vector)` at one end of one segment.
370    ///
371    /// The curvature VECTOR is `k = (r' × r'') × r' / |r'|⁴` — magnitude `κ`,
372    /// pointing at the centre of curvature — so it is invariant to the
373    /// parameterization (a rational arc is not parameterized in its own angle)
374    /// and it carries the bend's DIRECTION, which the magnitude alone does not.
375    /// A degree-1 segment has `r'' = 0` and so `k = 0`, which is the honest
376    /// reading: a line's curvature is zero.
377    fn frame_at(
378        curve: &NurbsCurve,
379        at_end: bool,
380        index: usize,
381        which: &str,
382    ) -> Result<(Vec3, Vec3), String> {
383        let [t0, t1] = curve.domain()?;
384        let parameter = if at_end { t1 } else { t0 };
385        let derivatives = curve.derivatives(parameter, 2)?;
386        let speed = derivatives[1].length();
387        let tangent = derivatives[1].normalized().map_err(|_| {
388            format!("sweepSolid: path tangent is degenerate at the {which} of segment {index}")
389        })?;
390        let curvature = derivatives[1]
391            .cross(derivatives[2])
392            .cross(derivatives[1])
393            .scale(1.0 / speed.powi(4));
394        Ok((tangent, curvature))
395    }
396
397    /// The number of segments.
398    pub fn len(&self) -> usize {
399        self.curves.len()
400    }
401
402    /// No segments — impossible through [`Self::new`], which refuses one.
403    pub fn is_empty(&self) -> bool {
404        self.curves.is_empty()
405    }
406
407    /// Segment `index`'s name, degrading to `<unnamed>` for a caller that named
408    /// none.
409    pub fn name(&self, index: usize) -> &str {
410        self.names
411            .get(index)
412            .map(String::as_str)
413            .unwrap_or("<unnamed>")
414    }
415
416    /// The first CORNER (a `G0` joint), if the path has one — what a builder that
417    /// skins between stations refuses on.
418    pub fn corner(&self) -> Option<&PathJoint> {
419        self.joints
420            .iter()
421            .find(|joint| joint.continuity == JointContinuity::G0)
422    }
423
424    /// This path is a CORNERED POLYLINE: every segment straight, with at least
425    /// one `G0` joint. The MITRE lane's selector, and the one predicate both the
426    /// builder and the two features read — the builder to pick the lane, the
427    /// features to know how many faces to name (one wall per segment × section
428    /// edge, rather than one per section edge).
429    ///
430    /// Each clause is a separate geometric reason:
431    ///   - STRAIGHT, because a mitre trims both sides to ONE plane, and only a
432    ///     straight segment has a single direction for that plane to bisect;
433    ///   - a CORNER, because a tangent-continuous polyline (collinear segments)
434    ///     is already carried by the skinning lane, which must keep it.
435    ///
436    /// CLOSED IS NOT A CLAUSE, and that is this predicate's whole content: a
437    /// closed cornered polyline — a picture FRAME — is mitred at every joint
438    /// INCLUDING the closing one, and comes out as one wall per (segment ×
439    /// section curve) with no caps at all. On a PLANAR loop the joint rotations
440    /// are all about the path plane's normal and sum to the loop's total turning,
441    /// `±2π`, so the section carried once round comes back as itself and the
442    /// closing mitre is built exactly like the others (`sweep_topology/miter.rs`
443    /// derives it, and measures the residual it predicts to be zero). A SPATIAL
444    /// loop's section returns rolled by the path's own holonomy, and the lane
445    /// closes it with a counter-twist shared over the sides — the same closure
446    /// policy the smooth ring lane applies ([`crate::SweepClosure`]).
447    ///
448    /// So this answers "is this the MITRE lane", and [`Self::closed`] answers
449    /// "does the result have caps". Both features read both.
450    pub fn cornered_polyline(&self) -> bool {
451        self.straight && self.corner().is_some()
452    }
453}
454