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