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///     ([`SweepPath::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 [`SweepPath::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, KernelRefusal> {
200        if curves.is_empty() {
201            return Err(KernelRefusal::input(
202                KernelStage::Collect,
203                "chain_empty",
204                "sweepSolid: path chain is empty",
205            ));
206        }
207        // --- Endpoints, and the SCALE every position test below is relative to.
208        //     Same form as the chainer's (`1e-5 * scale`, floored at 1.0 so a
209        //     path at the origin gets an absolute band): the two have to agree
210        //     about closure, since the chainer STOPS extending a run when its
211        //     tail returns to its head and this is what then reports that the
212        //     run it produced is a ring.
213        let mut endpoints = Vec::with_capacity(curves.len());
214        for curve in &curves {
215            let [t0, t1] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
216            endpoints.push((curve.evaluate(t0).or_refuse(KernelStage::Classify, "evaluate")?, curve.evaluate(t1).or_refuse(KernelStage::Classify, "evaluate")?));
217        }
218        let first_start = endpoints[0].0;
219        let scale = endpoints
220            .iter()
221            .flat_map(|(a, b)| [a, b])
222            .map(|point| point.sub(first_start).length())
223            .fold(1.0_f64, f64::max);
224        let join_tolerance = 1e-5 * scale;
225
226        // --- Sample the run once, for the planarity fit and for the travelled
227        //     distance the closure test needs a floor from. 16 per curve is the
228        //     sampling density `profile_anchor` uses to judge a profile's own
229        //     plane, so a path and a profile are held to the same resolution.
230        let mut samples = Vec::with_capacity(16 * curves.len() + 1);
231        for curve in &curves {
232            let [t0, t1] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
233            for index in 0..16 {
234                samples.push(curve.evaluate(t0 + (t1 - t0) * index as f64 / 16.0).or_refuse(KernelStage::Classify, "evaluate")?);
235            }
236        }
237        samples.push(endpoints[curves.len() - 1].1);
238        let travel: f64 = samples
239            .windows(2)
240            .map(|pair| pair[1].sub(pair[0]).length())
241            .sum();
242
243        // --- CLOSURE. The run returns to where it started, and has actually
244        //     gone somewhere: a zero-length curve trivially ends where it began,
245        //     and calling that a ring would send it into the closed lane instead
246        //     of the zero-length refusal that names it.
247        let closed = travel > join_tolerance
248            && endpoints[curves.len() - 1]
249                .1
250                .sub(first_start)
251                .length()
252                <= join_tolerance;
253
254        // --- PLANARITY. Newell over the sampled polygon gives the candidate
255        //     normal (exact for a planar sample set, whatever the winding), and
256        //     the samples are then CHECKED against it — a normal that fits
257        //     nothing is no answer. A degenerate normal (no enclosed area) is
258        //     `None` rather than an error: an open straight run is a perfectly
259        //     good path that simply has no one plane.
260        let planar = match crate::polygon::newell_normal(&samples).normalized() {
261            Ok(normal) => {
262                let deviation = samples
263                    .iter()
264                    .map(|point| point.sub(first_start).dot(normal).abs())
265                    .fold(0.0_f64, f64::max);
266                (deviation <= MAX_PLANARITY_DEVIATION * scale).then_some(normal)
267            }
268            Err(_) => None,
269        };
270
271        // --- JOINTS, interior then closing. A closed single-curve path has
272        //     exactly one joint — the curve meeting itself.
273        let mut joints = Vec::with_capacity(curves.len());
274        for index in 1..curves.len() {
275            joints.push(Self::joint(&curves, index - 1, index, false)?);
276        }
277        if closed {
278            joints.push(Self::joint(&curves, curves.len() - 1, 0, true)?);
279        }
280
281        // --- STRAIGHTNESS. The same test `SW`'s `translate` loop applies before
282        //     it reduces a segment to its chord: a degree-1 curve with two
283        //     control points. Recorded per PATH rather than per segment because
284        //     every consumer asks about the whole run — the mitre lane needs all
285        //     of them straight, and one curved segment is what sends a cornered
286        //     run back to the refusal.
287        let straight = curves
288            .iter()
289            .all(|curve| curve.degree == 1 && curve.control_points.len() == 2);
290
291        let screw_axes = vec![None; curves.len()];
292        Ok(Self {
293            curves,
294            names,
295            closed,
296            planar,
297            joints,
298            straight,
299            screw_axes,
300        })
301    }
302
303    /// Attach each segment's SCREW AXIS ([`Self::screw_axes`]), parallel to the
304    /// curves. A zero axis is refused rather than silently read as "no axis".
305    pub fn with_screw_axes(mut self, axes: Vec<Option<Vec3>>) -> Result<Self, KernelRefusal> {
306        if axes.len() != self.curves.len() {
307            return Err(KernelRefusal::input(
308                KernelStage::Collect,
309                "screw_axes_count",
310                format!(
311                    "sweepSolid: {} screw axes for a {}-segment path",
312                    axes.len(),
313                    self.curves.len()
314                ),
315            ));
316        }
317        self.screw_axes = axes
318            .into_iter()
319            .enumerate()
320            .map(|(index, axis)| {
321                axis.map(|axis| {
322                    axis.normalized().map_err(|_| {
323                        KernelRefusal::input(
324                            KernelStage::Collect,
325                            "screw_axis",
326                            format!(
327                                "sweepSolid: path segment '{}' has a degenerate screw axis",
328                                self.name(index)
329                            ),
330                        )
331                    })
332                })
333                .transpose()
334            })
335            .collect::<Result<_, _>>()?;
336        Ok(self)
337    }
338
339    /// Classify a borrowed chain — the entry a caller assembling curves for one
340    /// build uses ([`Self::new`] takes them by value for a caller that owns them).
341    pub fn from_curves(curves: &[NurbsCurve], names: &[String]) -> Result<Self, KernelRefusal> {
342        Self::new(curves.to_vec(), names.to_vec())
343    }
344
345    /// One joint's measurements and level. `before` is read at its domain END and
346    /// `after` at its domain START — the tangent the path ARRIVES with against
347    /// the one it DEPARTS with, which is the only pair that measures the joint
348    /// itself: sampling a station further along either side would fold that
349    /// segment's own curvature into the break and let a genuine corner through
350    /// on a curved segment.
351    fn joint(
352        curves: &[NurbsCurve],
353        before: usize,
354        after: usize,
355        closing: bool,
356    ) -> Result<PathJoint, KernelRefusal> {
357        let arriving = Self::frame_at(&curves[before], true, before, "end")?;
358        let departing = Self::frame_at(&curves[after], false, after, "start")?;
359        // Both unit, so the dot is the cosine of the break.
360        let tangent_break = arriving.0.dot(departing.0).clamp(-1.0, 1.0).acos();
361        let curvature_break = departing.1.sub(arriving.1).length();
362        let curvature_bar = MAX_JOINT_CURVATURE_BREAK
363            * (1.0 + arriving.1.length() + departing.1.length());
364        let continuity = if tangent_break > MAX_JOINT_TANGENT_BREAK {
365            JointContinuity::G0
366        } else if curvature_break > curvature_bar {
367            JointContinuity::G1
368        } else {
369            JointContinuity::G2
370        };
371        Ok(PathJoint {
372            before,
373            after,
374            tangent_break,
375            curvature_break,
376            continuity,
377            closing,
378        })
379    }
380
381    /// `(unit tangent, curvature vector)` at one end of one segment.
382    ///
383    /// The curvature VECTOR is `k = (r' × r'') × r' / |r'|⁴` — magnitude `κ`,
384    /// pointing at the centre of curvature — so it is invariant to the
385    /// parameterization (a rational arc is not parameterized in its own angle)
386    /// and it carries the bend's DIRECTION, which the magnitude alone does not.
387    /// A degree-1 segment has `r'' = 0` and so `k = 0`, which is the honest
388    /// reading: a line's curvature is zero.
389    fn frame_at(
390        curve: &NurbsCurve,
391        at_end: bool,
392        index: usize,
393        which: &str,
394    ) -> Result<(Vec3, Vec3), KernelRefusal> {
395        let [t0, t1] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
396        let parameter = if at_end { t1 } else { t0 };
397        let derivatives = curve.derivatives(parameter, 2).or_refuse(KernelStage::Classify, "derivatives")?;
398        let speed = derivatives[1].length();
399        let tangent = derivatives[1].normalized().map_err(|_| {
400            KernelRefusal::input(
401                KernelStage::Classify,
402                "path_tangent",
403                format!("sweepSolid: path tangent is degenerate at the {which} of segment {index}"),
404            )
405        })?;
406        let curvature = derivatives[1]
407            .cross(derivatives[2])
408            .cross(derivatives[1])
409            .scale(1.0 / speed.powi(4));
410        Ok((tangent, curvature))
411    }
412
413    /// The number of segments.
414    pub fn len(&self) -> usize {
415        self.curves.len()
416    }
417
418    /// No segments — impossible through [`Self::new`], which refuses one.
419    pub fn is_empty(&self) -> bool {
420        self.curves.is_empty()
421    }
422
423    /// Segment `index`'s name, degrading to `<unnamed>` for a caller that named
424    /// none.
425    pub fn name(&self, index: usize) -> &str {
426        self.names
427            .get(index)
428            .map(String::as_str)
429            .unwrap_or("<unnamed>")
430    }
431
432    /// The first CORNER (a `G0` joint), if the path has one — what a builder that
433    /// skins between stations refuses on.
434    pub fn corner(&self) -> Option<&PathJoint> {
435        self.joints
436            .iter()
437            .find(|joint| joint.continuity == JointContinuity::G0)
438    }
439
440    /// This path is a CORNERED POLYLINE: every segment straight, with at least
441    /// one `G0` joint. The MITRE lane's selector, and the one predicate both the
442    /// builder and the two features read — the builder to pick the lane, the
443    /// features to know how many faces to name (one wall per segment × section
444    /// edge, rather than one per section edge).
445    ///
446    /// Each clause is a separate geometric reason:
447    ///   - STRAIGHT, because a mitre trims both sides to ONE plane, and only a
448    ///     straight segment has a single direction for that plane to bisect;
449    ///   - a CORNER, because a tangent-continuous polyline (collinear segments)
450    ///     is already carried by the skinning lane, which must keep it.
451    ///
452    /// CLOSED IS NOT A CLAUSE, and that is this predicate's whole content: a
453    /// closed cornered polyline — a picture FRAME — is mitred at every joint
454    /// INCLUDING the closing one, and comes out as one wall per (segment ×
455    /// section curve) with no caps at all. On a PLANAR loop the joint rotations
456    /// are all about the path plane's normal and sum to the loop's total turning,
457    /// `±2π`, so the section carried once round comes back as itself and the
458    /// closing mitre is built exactly like the others (`sweep_topology/miter.rs`
459    /// derives it, and measures the residual it predicts to be zero). A SPATIAL
460    /// loop's section returns rolled by the path's own holonomy, and the lane
461    /// closes it with a counter-twist shared over the sides — the same closure
462    /// policy the smooth ring lane applies ([`crate::SweepClosure`]).
463    ///
464    /// So this answers "is this the MITRE lane", and [`Self::closed`] answers
465    /// "does the result have caps". Both features read both.
466    pub fn cornered_polyline(&self) -> bool {
467        self.straight && self.corner().is_some()
468    }
469}
470