brep_kernel/construction/sweep_topology/envelope.rs
1//! The swept envelope TRIMMED at its own self-intersection.
2//!
3//! Past `ρκ = 1` the sections a sweep skins through CROSS, and the wall lofted
4//! between them passes through itself. The correct solid there is not "no
5//! solid": it is the union of the swept SECTIONS — the envelope trimmed where
6//! it turns back through itself — which for the one configuration with a closed
7//! form is a REVOLVE, and is built here as one.
8//!
9//! ## The configuration, and why it is the only one
10//!
11//! A circle of radius `r` carried square to a planar CIRCULAR path of radius
12//! `R` sweeps a solid of REVOLUTION: every placed section is the start section
13//! rotated about the path's own axis, so the union of the placed DISCS is the
14//! revolution of one disc. In the meridian half-plane that disc is centred at
15//! `(R, 0)` with radius `r`, and for `r > R` it CROSSES the axis — which is the
16//! same statement as `ρκ ≥ 1`, since `ρ = r` and `κ = 1/R` at every station.
17//!
18//! The union in the half-plane is therefore the disc TRUNCATED at the axis
19//! together with the REFLECTION of the part that crossed, and each of those is
20//! revolved separately:
21//!
22//! ```text
23//! near arc (x ≥ 0) + the axis chord, revolved Θ → the APPLE
24//! far arc (x ≤ 0) + the axis chord, revolved Θ → the LEMON
25//! ```
26//!
27//! The far arc already SITS in the half-plane at azimuth π (its points have
28//! `x < 0`), so the same `+Θ` revolve carries it over the azimuths `[π, π+Θ]` —
29//! which is where the section's far side really does sweep material. The two
30//! lobes are two BODIES: they meet only along the axis chord, and a shell is
31//! edge-connected by definition.
32//!
33//! A FULL turn is the exception and the one-body case: the lemon then lies
34//! INSIDE the apple (every point of the reflected part is within the truncated
35//! disc), so the apple alone is the swept solid — the outer sheet of the
36//! SPINDLE TORUS, with a dimple at each pole.
37//!
38//! ## The closed forms
39//!
40//! Pappus on each half-plane region, with `A_seg` the area of the circular
41//! segment the axis cuts off and `M = R·A_seg − (2/3)(r² − R²)^{3/2}` its first
42//! moment `∫∫_{x<0} x dA` (negative):
43//!
44//! ```text
45//! A_seg = r²·acos(R/r) − R·√(r² − R²)
46//! V_apple = Θ·(π r² R − M)
47//! V_lemon = Θ·(−M)
48//! V_apple(2π) = 2π²R r² + V_lemon(2π) the ring formula plus the lemon
49//! ```
50//!
51//! `R = 0` reads `(4π/3)r³` (a sphere) and `R → r` reads `Θ π r² R` with the
52//! lemon vanishing (the horn torus), which is what pins the algebra at both
53//! ends.
54//!
55//! ## What it does NOT cover, and says so
56//!
57//! A non-circular section, a section not centred on the path under `Rigid`, a
58//! path that is not one circle, and a turn in `[π, 2π)` — where the far lobe
59//! overlaps the near one and the union stops being two disjoint revolves. Each
60//! is refused BY NAME with the measurement that decided it, and the tight-bend
61//! refusal carries the reason so a caller learns which half of the
62//! configuration it missed.
63
64use super::*;
65
66/// How far a path or section sample may sit off the circle fitted to it,
67/// relative to that circle's own radius, and still be called a circle.
68///
69/// The comparand is the RADIUS, not the model: both curves here come from a
70/// sketch as exact rational arcs, which reproduce their circle to machine
71/// precision, so this is four orders of slack over what an exact arc reads and
72/// still refuses a spline that merely looks round. The measured residual is
73/// carried on the plan and quoted by every refusal, so the bar is never the
74/// only thing reported.
75const CIRCLE_RESIDUAL_BAR: f64 = 1e-7;
76
77/// Below this — again relative to the section radius — the section circle is
78/// TANGENT to the axis rather than crossing it: the lemon is empty, the axis
79/// chord has no length, and the apple is the whole disc revolved.
80///
81/// `√(r² − R²)` is the chord's half-length, so a section `1e-14·r` past the bar
82/// leaves a chord of `1.4e-7·r` — the profile would be a line no revolve should
83/// be asked to carry. The volume this rounds away is the lemon's, which at that
84/// separation is `Θ·(2/3)·(1.4e-7·r)³` — below 1e-20·r³.
85const TANGENT_CHORD_BAR: f64 = 1e-7;
86
87/// A circle fitted to a sampled curve run, with the residual that says whether
88/// the run really is one.
89struct FittedCircle {
90 centre: Vec3,
91 /// Unit normal of the circle's plane, oriented so the run TRAVELS `+θ`.
92 axis: Vec3,
93 radius: f64,
94 /// Worst `| |p − centre| − radius |` and out-of-plane distance over every
95 /// sample, relative to the radius.
96 residual: f64,
97 /// Total turn the run makes about `axis`, summed over the samples.
98 turn: f64,
99}
100
101/// The recognized configuration: a circular section on a circular planar path,
102/// tight enough that the envelope crosses itself.
103#[derive(Debug, Clone, Copy)]
104pub struct SweptEnvelope {
105 /// The path circle's centre — the revolve's origin.
106 pub centre: Vec3,
107 /// The path plane's unit normal, oriented so the sweep runs `+turn`.
108 pub axis: Vec3,
109 /// Unit vector from `centre` to the section's centre.
110 pub radial: Vec3,
111 /// `R`, the path's radius of curvature.
112 pub major: f64,
113 /// `r`, the section's radius.
114 pub minor: f64,
115 /// `Θ`, the turn the sweep makes, in `(0, 2π]`.
116 pub turn: f64,
117 /// The worst relative residual either circle read on recognition.
118 pub residual: f64,
119}
120
121impl SweptEnvelope {
122 /// `ρκ` — `r/R`. The envelope self-intersects at and past 1.
123 pub fn ratio(&self) -> f64 {
124 self.minor / self.major
125 }
126
127 /// Half the axis chord, `√(r² − R²)`: how far the section reaches past the
128 /// axis on each side. Zero for a section TANGENT to the axis.
129 pub fn chord_half_length(&self) -> f64 {
130 (self.minor * self.minor - self.major * self.major)
131 .max(0.0)
132 .sqrt()
133 }
134
135 /// True when the section only TOUCHES the axis: no lemon, no chord, and the
136 /// apple is the whole disc revolved.
137 pub fn tangent(&self) -> bool {
138 self.chord_half_length() <= TANGENT_CHORD_BAR * self.minor
139 }
140
141 /// `∫∫_{x<0} x dA` over the section disc — the moment of the part the axis
142 /// cuts off, negative, and the one term both closed forms are built from.
143 fn cut_moment(&self) -> f64 {
144 // The ratio is CLAMPED: the lane engages a hair below `ρκ = 1` (the fit's
145 // own slack), where `R/r` is a shade over 1 and `acos` would be NaN. At
146 // the bar the whole term vanishes — `A_seg = 0` — and the apple's form
147 // reduces to `Θ π r² R`, the plain Pappus reading the sub-bar lane has.
148 let gap = (self.minor * self.minor - self.major * self.major).max(0.0);
149 let segment = self.minor * self.minor * (self.major / self.minor).clamp(-1.0, 1.0).acos()
150 - self.major * gap.sqrt();
151 self.major * segment - (2.0 / 3.0) * gap.powf(1.5)
152 }
153
154 /// The APPLE's closed-form volume: `Θ·∫∫_{x≥0} x dA`.
155 pub fn apple_volume(&self) -> f64 {
156 self.turn * (std::f64::consts::PI * self.minor * self.minor * self.major - self.cut_moment())
157 }
158
159 /// The LEMON's closed-form volume: `Θ·∫∫_{x<0} |x| dA`. Zero for a tangent
160 /// section, and never counted twice — a FULL turn puts the lemon inside the
161 /// apple and [`swept_envelope_volume`] is the one to read there.
162 pub fn lemon_volume(&self) -> f64 {
163 if self.tangent() {
164 return 0.0;
165 }
166 self.turn * -self.cut_moment()
167 }
168}
169
170/// The whole swept solid's closed-form volume: both lobes for a partial turn,
171/// the apple alone for a full one (where the lemon is inside it).
172pub fn swept_envelope_volume(plan: &SweptEnvelope) -> f64 {
173 if plan.turn >= std::f64::consts::TAU - 1e-9 {
174 plan.apple_volume()
175 } else {
176 plan.apple_volume() + plan.lemon_volume()
177 }
178}
179
180/// Sample every curve of a run end to end, `per_curve` points each plus the
181/// run's final point.
182fn run_samples(curves: &[NurbsCurve], per_curve: usize) -> Result<Vec<Vec3>, String> {
183 let mut samples = Vec::with_capacity(curves.len() * per_curve + 1);
184 for curve in curves {
185 let [start, end] = curve.domain()?;
186 for index in 0..per_curve {
187 samples.push(curve.evaluate(start + (end - start) * index as f64 / per_curve as f64)?);
188 }
189 }
190 let last = curves.last().ok_or("envelope: empty curve run")?;
191 samples.push(last.evaluate(last.domain()?[1])?);
192 Ok(samples)
193}
194
195/// Fit a circle to a sampled run BY RECONSTRUCTION: take the centre from three
196/// spread samples, then measure every sample against it.
197///
198/// The fit is three points and the verdict is all of them, which is what makes
199/// this a measurement rather than an assumption — a spline through three points
200/// of a circle fits the same centre and fails the residual.
201fn fit_circle(samples: &[Vec3]) -> Result<Option<FittedCircle>, String> {
202 if samples.len() < 5 {
203 return Ok(None);
204 }
205 let first = samples[0];
206 let middle = samples[samples.len() / 3];
207 let last = samples[2 * samples.len() / 3];
208 let Some(centre) = circumcentre(first, middle, last) else {
209 return Ok(None);
210 };
211 let radial = first.sub(centre);
212 let radius = radial.length();
213 if radius <= 0.0 || !radius.is_finite() {
214 return Ok(None);
215 }
216 let Ok(axis) = radial.cross(middle.sub(centre)).normalized() else {
217 return Ok(None);
218 };
219 let mut residual: f64 = 0.0;
220 for sample in samples {
221 let delta = sample.sub(centre);
222 residual = residual.max((delta.length() - radius).abs() / radius);
223 residual = residual.max(delta.dot(axis).abs() / radius);
224 }
225 // The TURN, summed over consecutive samples so a run past half a turn is
226 // measured rather than wrapped. Each step is well under π at 16 samples per
227 // curve, so its own arccos is unambiguous; the sign says the run travels the
228 // way `axis` was oriented from the first three points.
229 let mut turn = 0.0;
230 for pair in samples.windows(2) {
231 let from = pair[0].sub(centre);
232 let into = pair[1].sub(centre);
233 let cross = from.cross(into);
234 let step = cross.length().atan2(from.dot(into));
235 if cross.dot(axis) < 0.0 && step > CIRCLE_RESIDUAL_BAR {
236 // The run doubled back: not a single arc travelled one way.
237 return Ok(None);
238 }
239 turn += step;
240 }
241 Ok(Some(FittedCircle {
242 centre,
243 axis,
244 radius,
245 residual,
246 turn,
247 }))
248}
249
250/// The centre of the circle through three points, or `None` when they are
251/// collinear.
252fn circumcentre(a: Vec3, b: Vec3, c: Vec3) -> Option<Vec3> {
253 let ab = b.sub(a);
254 let ac = c.sub(a);
255 let normal = ab.cross(ac);
256 let denominator = 2.0 * normal.dot(normal);
257 if denominator <= 0.0 || !denominator.is_finite() {
258 return None;
259 }
260 let term = ac
261 .cross(normal)
262 .scale(ab.dot(ab))
263 .add(normal.cross(ab).scale(ac.dot(ac)));
264 Some(a.add(term.scale(1.0 / denominator)))
265}
266
267/// Recognize the one configuration the envelope lane builds, or say — in words
268/// a refusal can quote — which half of it this sweep is not.
269///
270/// `Ok(Ok(plan))` is the configuration; `Ok(Err(reason))` is a measured reason
271/// it is not. A path that is not tight at all reads `Err` too: this lane only
272/// ever speaks past `ρκ = 1`, and below it the skinning lanes own the shape.
273pub fn recognize_swept_envelope(
274 profile: &[NurbsCurve],
275 path: &SweepPath,
276 placement_mode: SectionPlacement,
277) -> Result<Result<SweptEnvelope, String>, String> {
278 let path_samples = run_samples(&path.curves, 16)?;
279 let Some(path_circle) = fit_circle(&path_samples)? else {
280 return Ok(Err("its path is not a circular arc".into()));
281 };
282 if path_circle.residual > CIRCLE_RESIDUAL_BAR {
283 return Ok(Err(format!(
284 "its path is not a circular arc (its samples sit up to {:.3e} of the fitted radius \
285 {:.6} off that circle, against a bar of {CIRCLE_RESIDUAL_BAR:.0e})",
286 path_circle.residual, path_circle.radius
287 )));
288 }
289 let Some(section_circle) = fit_circle(&run_samples(profile, 16)?)? else {
290 return Ok(Err("its section is not a circle".into()));
291 };
292 if section_circle.residual > CIRCLE_RESIDUAL_BAR {
293 return Ok(Err(format!(
294 "its section is not a circle (its samples sit up to {:.3e} of the fitted radius \
295 {:.6} off that circle, against a bar of {CIRCLE_RESIDUAL_BAR:.0e})",
296 section_circle.residual, section_circle.radius
297 )));
298 }
299 // A CLOSED loop every one of whose points lies on one circle IS that whole
300 // circle — a partial arc cannot close — so the full turn is checked rather
301 // than assumed, and a section that is a circular arc plus something else has
302 // already failed the residual above.
303 let last = profile
304 .last()
305 .ok_or_else(|| "envelope: empty section".to_string())?;
306 let closure = last
307 .evaluate(last.domain()?[1])?
308 .sub(profile[0].evaluate(profile[0].domain()?[0])?)
309 .length();
310 if closure > CIRCLE_RESIDUAL_BAR * section_circle.radius {
311 return Ok(Err(format!(
312 "its section is not a closed circle (it ends {closure:.3e} from where it starts)"
313 )));
314 }
315 let major = path_circle.radius;
316 let minor = section_circle.radius;
317 // The bar is `ρκ = 1`, and it is read off the two FITTED radii rather than
318 // off the sampled reach the tight-bend guard uses — which under-reads a
319 // circle by 3.0e-4·r, so a section exactly as wide as its bend would fall on
320 // whichever side of the sampling. The `1 − 1e-7` is the fit's own slack: two
321 // radii fitted from exact rational arcs agree to 1e-15, and a section that
322 // close to the bar sweeps a lemon of volume below 1e-20·r³ — nothing this
323 // lane could resolve either way. Below it the skinning lanes own the shape,
324 // and every sweep that builds today keeps the builder it has.
325 if minor < major * (1.0 - CIRCLE_RESIDUAL_BAR) {
326 return Ok(Err(format!(
327 "its bend is not tighter than its section (ρ·κ = {:.6} < 1)",
328 minor / major
329 )));
330 }
331 let start = path_samples[0];
332 let radial = start.sub(path_circle.centre).normalized()?;
333 // `Rigid` carries the section from where it was DRAWN, so it has to be drawn
334 // centred on the path and square to it for the sweep to be a revolve;
335 // `Transplant` MOVES it onto the path and squares it, so a circle drawn
336 // anywhere becomes the same placed circle and only its radius matters.
337 if placement_mode == SectionPlacement::Rigid {
338 let offset = section_circle.centre.sub(start).length();
339 if offset > CIRCLE_RESIDUAL_BAR * minor.max(major) {
340 return Ok(Err(format!(
341 "its section is not centred on the path (its centre sits {offset:.6} off the \
342 path's start, and `pathAlign` carries a section from where it was drawn)"
343 )));
344 }
345 let out_of_plane = section_circle
346 .axis
347 .dot(path_circle.axis)
348 .abs()
349 .max(section_circle.axis.dot(radial).abs());
350 if out_of_plane > CIRCLE_RESIDUAL_BAR {
351 return Ok(Err(format!(
352 "its section is not square to the path (its plane's normal is {:.3e} off the \
353 path's start tangent)",
354 out_of_plane
355 )));
356 }
357 }
358 let turn = if path.closed {
359 std::f64::consts::TAU
360 } else {
361 path_circle.turn
362 };
363 if path.closed && (path_circle.turn - std::f64::consts::TAU).abs() > CIRCLE_RESIDUAL_BAR {
364 return Ok(Err(format!(
365 "its path closes without going once round its own circle (it turns {:.6} rad)",
366 path_circle.turn
367 )));
368 }
369 Ok(Ok(SweptEnvelope {
370 centre: path_circle.centre,
371 axis: path_circle.axis,
372 radial,
373 major,
374 minor,
375 turn,
376 residual: path_circle.residual.max(section_circle.residual),
377 }))
378}
379
380/// The prefix every refusal from this lane starts with, so a caller can
381/// CLASSIFY one without matching free text.
382pub const SWEEP_ENVELOPE_REFUSAL: &str = "sweepSolid: the swept envelope";
383
384/// Build the swept union: the APPLE first, then the LEMON where the turn leaves
385/// one outside it.
386///
387/// One body for a FULL turn (the lemon is inside the apple) and for a section
388/// merely TANGENT to the axis (there is no lemon); two for a turn under half a
389/// revolution. A turn in `[π, 2π)` is refused by name — the far lobe then
390/// OVERLAPS the near one and the union is no longer two disjoint revolves.
391pub fn build_swept_envelope(plan: &SweptEnvelope) -> Result<Vec<BrepSolid>, String> {
392 use std::f64::consts::{PI, TAU};
393 if !(plan.turn > 0.0 && plan.turn <= TAU + 1e-9) {
394 return Err(format!(
395 "{SWEEP_ENVELOPE_REFUSAL} needs a turn in (0, 2π]; this path turns {:.6} rad",
396 plan.turn
397 ));
398 }
399 let full = plan.turn >= TAU - 1e-9;
400 if full && plan.tangent() {
401 return Err(format!(
402 "{SWEEP_ENVELOPE_REFUSAL} of a section exactly as wide as its bend (ρ·κ = {:.6}, the \
403 section reaching {:.3e} of its own radius past the axis) carried ALL the way round \
404 is a HORN torus: the tube touches the axis at one point and the solid is PINCHED \
405 there, which is not a boundary representation this kernel can hold — its Euler count \
406 reads one short of a sphere's and no face pair meets along an edge at that point. \
407 Sweep less than a full turn, where the same section builds, or widen the bend",
408 plan.ratio(),
409 plan.chord_half_length() / plan.minor
410 ));
411 }
412 if !full && plan.turn >= PI && !plan.tangent() {
413 return Err(format!(
414 "{SWEEP_ENVELOPE_REFUSAL} of a section that crosses the axis (ρ·κ = {:.6}) sweeps a \
415 SECOND lobe at the azimuths half a turn from the path, and this path turns {:.6} rad \
416 — past π that lobe runs back into the first and the union is no longer two disjoint \
417 revolves. Sweep the run in pieces of less than half a turn, or all the way round",
418 plan.ratio(),
419 plan.turn
420 ));
421 }
422 let turn = if full { TAU } else { plan.turn };
423 let centre = plan.centre;
424 let up = plan.axis;
425 let section_centre = centre.add(plan.radial.scale(plan.major));
426 let mut bodies = Vec::with_capacity(2);
427 bodies.push(revolve_lobe(
428 &near_profile(plan, section_centre)?,
429 centre,
430 up,
431 turn,
432 )?);
433 if !full && !plan.tangent() {
434 bodies.push(revolve_lobe(
435 &far_profile(plan, section_centre)?,
436 centre,
437 up,
438 turn,
439 )?);
440 }
441 Ok(bodies)
442}
443
444/// The section arc on the AXIS side of the path (`x ≥ 0`), closed by the axis
445/// chord — or, for a section merely tangent to the axis, the whole section
446/// circle split at the tangency and at the outer point (there is no chord to
447/// close with, and a revolve may touch its axis at a boundary VERTEX).
448fn near_profile(plan: &SweptEnvelope, section_centre: Vec3) -> Result<Vec<NurbsCurve>, String> {
449 use std::f64::consts::PI;
450 let (u, w) = (plan.radial, plan.axis);
451 if plan.tangent() {
452 return Ok(vec![
453 make_arc(section_centre, u, w, plan.minor, -PI, 0.0)?,
454 make_arc(section_centre, u, w, plan.minor, 0.0, PI)?,
455 ]);
456 }
457 let phi = (-plan.major / plan.minor).acos();
458 let half = plan.chord_half_length();
459 Ok(vec![
460 make_arc(section_centre, u, w, plan.minor, -phi, phi)?,
461 make_line(
462 plan.centre.add(w.scale(half)),
463 plan.centre.sub(w.scale(half)),
464 )?,
465 ])
466}
467
468/// The section arc on the FAR side of the axis (`x ≤ 0`), closed by the same
469/// axis chord. It already lies in the half-plane at azimuth π, so revolving it
470/// through the same `+Θ` carries it over `[π, π+Θ]` — where the section's far
471/// side really does sweep.
472fn far_profile(plan: &SweptEnvelope, section_centre: Vec3) -> Result<Vec<NurbsCurve>, String> {
473 use std::f64::consts::TAU;
474 let (u, w) = (plan.radial, plan.axis);
475 let phi = (-plan.major / plan.minor).acos();
476 let half = plan.chord_half_length();
477 Ok(vec![
478 make_arc(section_centre, u, w, plan.minor, phi, TAU - phi)?,
479 make_line(
480 plan.centre.sub(w.scale(half)),
481 plan.centre.add(w.scale(half)),
482 )?,
483 ])
484}
485
486/// Revolve one lobe's profile and put it through the soundness acceptance — the
487/// same gate the loft's exit applies, because the whole point of this lane is
488/// that the loft's answer here passes through itself.
489fn revolve_lobe(
490 profile: &[NurbsCurve],
491 centre: Vec3,
492 axis: Vec3,
493 turn: f64,
494) -> Result<BrepSolid, String> {
495 let solid = crate::revolve_profile_brep(profile, centre, axis, turn)
496 .map_err(|error| format!("{SWEEP_ENVELOPE_REFUSAL} could not be revolved: {error}"))?;
497 crate::accept_sound(solid, "sweepSolid")
498}
499
500/// The swept union as BODIES — the entry for a caller that can take more than
501/// one, which is what a partial turn past `ρκ = 1` really sweeps.
502///
503/// Refuses by name, quoting the measurement, when the configuration is not the
504/// one this lane builds.
505pub fn sweep_envelope_bodies(
506 profile: &[NurbsCurve],
507 path: &SweepPath,
508 placement_mode: SectionPlacement,
509) -> Result<Vec<BrepSolid>, String> {
510 match recognize_swept_envelope(profile, path, placement_mode)? {
511 Ok(plan) => build_swept_envelope(&plan),
512 Err(reason) => Err(format!(
513 "{SWEEP_ENVELOPE_REFUSAL} trimmed at its own self-intersection is built for a CIRCLE \
514 carried along one circular planar arc, and this sweep is not that: {reason}. Nothing \
515 else has a closed form to gate the trim with, so it is refused rather than \
516 approximated"
517 )),
518 }
519}