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