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>, KernelRefusal> {
183 let mut samples = Vec::with_capacity(curves.len() * per_curve + 1);
184 for curve in curves {
185 let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
186 for index in 0..per_curve {
187 samples.push(curve.evaluate(start + (end - start) * index as f64 / per_curve as f64).or_refuse(KernelStage::Collect, "evaluate")?);
188 }
189 }
190 let last = curves.last().ok_or_else(|| {
191 KernelRefusal::input(KernelStage::Collect, "empty_run", "envelope: empty curve run")
192 })?;
193 samples.push(last.evaluate(last.domain().or_refuse(KernelStage::Collect, "domain")?[1]).or_refuse(KernelStage::Collect, "evaluate")?);
194 Ok(samples)
195}
196
197/// Fit a circle to a sampled run BY RECONSTRUCTION: take the centre from three
198/// spread samples, then measure every sample against it.
199///
200/// The fit is three points and the verdict is all of them, which is what makes
201/// this a measurement rather than an assumption — a spline through three points
202/// of a circle fits the same centre and fails the residual.
203fn fit_circle(samples: &[Vec3]) -> Result<Option<FittedCircle>, KernelRefusal> {
204 if samples.len() < 5 {
205 return Ok(None);
206 }
207 let first = samples[0];
208 let middle = samples[samples.len() / 3];
209 let last = samples[2 * samples.len() / 3];
210 let Some(centre) = circumcentre(first, middle, last) else {
211 return Ok(None);
212 };
213 let radial = first.sub(centre);
214 let radius = radial.length();
215 if radius <= 0.0 || !radius.is_finite() {
216 return Ok(None);
217 }
218 let Ok(axis) = radial.cross(middle.sub(centre)).normalized() else {
219 return Ok(None);
220 };
221 let mut residual: f64 = 0.0;
222 for sample in samples {
223 let delta = sample.sub(centre);
224 residual = residual.max((delta.length() - radius).abs() / radius);
225 residual = residual.max(delta.dot(axis).abs() / radius);
226 }
227 // The TURN, summed over consecutive samples so a run past half a turn is
228 // measured rather than wrapped. Each step is well under π at 16 samples per
229 // curve, so its own arccos is unambiguous; the sign says the run travels the
230 // way `axis` was oriented from the first three points.
231 let mut turn = 0.0;
232 for pair in samples.windows(2) {
233 let from = pair[0].sub(centre);
234 let into = pair[1].sub(centre);
235 let cross = from.cross(into);
236 let step = cross.length().atan2(from.dot(into));
237 if cross.dot(axis) < 0.0 && step > CIRCLE_RESIDUAL_BAR {
238 // The run doubled back: not a single arc travelled one way.
239 return Ok(None);
240 }
241 turn += step;
242 }
243 Ok(Some(FittedCircle {
244 centre,
245 axis,
246 radius,
247 residual,
248 turn,
249 }))
250}
251
252/// The centre of the circle through three points, or `None` when they are
253/// collinear.
254fn circumcentre(a: Vec3, b: Vec3, c: Vec3) -> Option<Vec3> {
255 crate::circumcenter(a, b, c)
256}
257
258/// Recognize the one configuration the envelope lane builds, or say — in words
259/// a refusal can quote — which half of it this sweep is not.
260///
261/// `Ok(Ok(plan))` is the configuration; `Ok(Err(reason))` is a measured reason
262/// it is not. A path that is not tight at all reads `Err` too: this lane only
263/// ever speaks past `ρκ = 1`, and below it the skinning lanes own the shape.
264pub fn recognize_swept_envelope(
265 profile: &[NurbsCurve],
266 path: &SweepPath,
267 placement_mode: SectionPlacement,
268) -> Result<Result<SweptEnvelope, String>, KernelRefusal> {
269 let path_samples = run_samples(&path.curves, 16)?;
270 let Some(path_circle) = fit_circle(&path_samples)? else {
271 return Ok(Err("its path is not a circular arc".into()));
272 };
273 if path_circle.residual > CIRCLE_RESIDUAL_BAR {
274 return Ok(Err(format!(
275 "its path is not a circular arc (its samples sit up to {:.3e} of the fitted radius \
276 {:.6} off that circle, against a bar of {CIRCLE_RESIDUAL_BAR:.0e})",
277 path_circle.residual, path_circle.radius
278 )));
279 }
280 let Some(section_circle) = fit_circle(&run_samples(profile, 16)?)? else {
281 return Ok(Err("its section is not a circle".into()));
282 };
283 if section_circle.residual > CIRCLE_RESIDUAL_BAR {
284 return Ok(Err(format!(
285 "its section is not a circle (its samples sit up to {:.3e} of the fitted radius \
286 {:.6} off that circle, against a bar of {CIRCLE_RESIDUAL_BAR:.0e})",
287 section_circle.residual, section_circle.radius
288 )));
289 }
290 // A CLOSED loop every one of whose points lies on one circle IS that whole
291 // circle — a partial arc cannot close — so the full turn is checked rather
292 // than assumed, and a section that is a circular arc plus something else has
293 // already failed the residual above.
294 let last = profile.last().ok_or_else(|| {
295 KernelRefusal::input(KernelStage::Collect, "empty_section", "envelope: empty section")
296 })?;
297 let closure = last
298 .evaluate(last.domain().or_refuse(KernelStage::Classify, "domain")?[1]).or_refuse(KernelStage::Classify, "evaluate")?
299 .sub(profile[0].evaluate(profile[0].domain().or_refuse(KernelStage::Classify, "domain")?[0]).or_refuse(KernelStage::Classify, "evaluate")?)
300 .length();
301 if closure > CIRCLE_RESIDUAL_BAR * section_circle.radius {
302 return Ok(Err(format!(
303 "its section is not a closed circle (it ends {closure:.3e} from where it starts)"
304 )));
305 }
306 let major = path_circle.radius;
307 let minor = section_circle.radius;
308 // The bar is `ρκ = 1`, and it is read off the two FITTED radii rather than
309 // off the sampled reach the tight-bend guard uses — which under-reads a
310 // circle by 3.0e-4·r, so a section exactly as wide as its bend would fall on
311 // whichever side of the sampling. The `1 − 1e-7` is the fit's own slack: two
312 // radii fitted from exact rational arcs agree to 1e-15, and a section that
313 // close to the bar sweeps a lemon of volume below 1e-20·r³ — nothing this
314 // lane could resolve either way. Below it the skinning lanes own the shape,
315 // and every sweep that builds today keeps the builder it has.
316 if minor < major * (1.0 - CIRCLE_RESIDUAL_BAR) {
317 return Ok(Err(format!(
318 "its bend is not tighter than its section (ρ·κ = {:.6} < 1)",
319 minor / major
320 )));
321 }
322 let start = path_samples[0];
323 let radial = start.sub(path_circle.centre).normalized().or_refuse(KernelStage::Classify, "normalized")?;
324 // `Rigid` carries the section from where it was DRAWN, so it has to be drawn
325 // centred on the path and square to it for the sweep to be a revolve;
326 // `Transplant` MOVES it onto the path and squares it, so a circle drawn
327 // anywhere becomes the same placed circle and only its radius matters.
328 if placement_mode == SectionPlacement::Rigid {
329 let offset = section_circle.centre.sub(start).length();
330 if offset > CIRCLE_RESIDUAL_BAR * minor.max(major) {
331 return Ok(Err(format!(
332 "its section is not centred on the path (its centre sits {offset:.6} off the \
333 path's start, and `pathAlign` carries a section from where it was drawn)"
334 )));
335 }
336 let out_of_plane = section_circle
337 .axis
338 .dot(path_circle.axis)
339 .abs()
340 .max(section_circle.axis.dot(radial).abs());
341 if out_of_plane > CIRCLE_RESIDUAL_BAR {
342 return Ok(Err(format!(
343 "its section is not square to the path (its plane's normal is {:.3e} off the \
344 path's start tangent)",
345 out_of_plane
346 )));
347 }
348 }
349 let turn = if path.closed {
350 std::f64::consts::TAU
351 } else {
352 path_circle.turn
353 };
354 if path.closed && (path_circle.turn - std::f64::consts::TAU).abs() > CIRCLE_RESIDUAL_BAR {
355 return Ok(Err(format!(
356 "its path closes without going once round its own circle (it turns {:.6} rad)",
357 path_circle.turn
358 )));
359 }
360 Ok(Ok(SweptEnvelope {
361 centre: path_circle.centre,
362 axis: path_circle.axis,
363 radial,
364 major,
365 minor,
366 turn,
367 residual: path_circle.residual.max(section_circle.residual),
368 }))
369}
370
371/// The prefix every refusal from this lane starts with, so a caller can
372/// CLASSIFY one without matching free text.
373pub const SWEEP_ENVELOPE_REFUSAL: &str = "sweepSolid: the swept envelope";
374
375/// Build the swept union: the APPLE first, then the LEMON where the turn leaves
376/// one outside it.
377///
378/// One body for a FULL turn (the lemon is inside the apple) and for a section
379/// merely TANGENT to the axis (there is no lemon); two for a turn under half a
380/// revolution. A turn in `[π, 2π)` is refused by name — the far lobe then
381/// OVERLAPS the near one and the union is no longer two disjoint revolves.
382pub fn build_swept_envelope(plan: &SweptEnvelope) -> Result<Vec<BrepSolid>, KernelRefusal> {
383 use std::f64::consts::{PI, TAU};
384 if !(plan.turn > 0.0 && plan.turn <= TAU + 1e-9) {
385 return Err(KernelRefusal::input(
386 KernelStage::Collect,
387 "turn_range",
388 format!(
389 "{SWEEP_ENVELOPE_REFUSAL} needs a turn in (0, 2π]; this path turns {:.6} rad",
390 plan.turn
391 ),
392 ));
393 }
394 let full = plan.turn >= TAU - 1e-9;
395 if full && plan.tangent() {
396 return Err(KernelRefusal::unsupported(
397 KernelStage::Classify,
398 "horn_torus",
399 format!(
400 "{SWEEP_ENVELOPE_REFUSAL} of a section exactly as wide as its bend (ρ·κ = {:.6}, the \
401 section reaching {:.3e} of its own radius past the axis) carried ALL the way round \
402 is a HORN torus: the tube touches the axis at one point and the solid is PINCHED \
403 there, which is not a boundary representation this kernel can hold — its Euler count \
404 reads one short of a sphere's and no face pair meets along an edge at that point. \
405 Sweep less than a full turn, where the same section builds, or widen the bend",
406 plan.ratio(),
407 plan.chord_half_length() / plan.minor
408 ),
409 ));
410 }
411 if !full && plan.turn >= PI && !plan.tangent() {
412 return Err(KernelRefusal::unsupported(
413 KernelStage::Classify,
414 "lobe_overlap",
415 format!(
416 "{SWEEP_ENVELOPE_REFUSAL} of a section that crosses the axis (ρ·κ = {:.6}) sweeps a \
417 SECOND lobe at the azimuths half a turn from the path, and this path turns {:.6} rad \
418 — past π that lobe runs back into the first and the union is no longer two disjoint \
419 revolves. Sweep the run in pieces of less than half a turn, or all the way round",
420 plan.ratio(),
421 plan.turn
422 ),
423 ));
424 }
425 let turn = if full { TAU } else { plan.turn };
426 let centre = plan.centre;
427 let up = plan.axis;
428 let section_centre = centre.add(plan.radial.scale(plan.major));
429 let mut bodies = Vec::with_capacity(2);
430 bodies.push(revolve_lobe(
431 &near_profile(plan, section_centre)?,
432 centre,
433 up,
434 turn,
435 )?);
436 if !full && !plan.tangent() {
437 bodies.push(revolve_lobe(
438 &far_profile(plan, section_centre)?,
439 centre,
440 up,
441 turn,
442 )?);
443 }
444 Ok(bodies)
445}
446
447/// The section arc on the AXIS side of the path (`x ≥ 0`), closed by the axis
448/// chord — or, for a section merely tangent to the axis, the whole section
449/// circle split at the tangency and at the outer point (there is no chord to
450/// close with, and a revolve may touch its axis at a boundary VERTEX).
451fn near_profile(plan: &SweptEnvelope, section_centre: Vec3) -> Result<Vec<NurbsCurve>, KernelRefusal> {
452 use std::f64::consts::PI;
453 let (u, w) = (plan.radial, plan.axis);
454 if plan.tangent() {
455 return Ok(vec![
456 make_arc(section_centre, u, w, plan.minor, -PI, 0.0).or_refuse(KernelStage::Fragment, "make_arc")?,
457 make_arc(section_centre, u, w, plan.minor, 0.0, PI).or_refuse(KernelStage::Fragment, "make_arc")?,
458 ]);
459 }
460 let phi = (-plan.major / plan.minor).acos();
461 let half = plan.chord_half_length();
462 Ok(vec![
463 make_arc(section_centre, u, w, plan.minor, -phi, phi).or_refuse(KernelStage::Fragment, "make_arc")?,
464 make_line(
465 plan.centre.add(w.scale(half)),
466 plan.centre.sub(w.scale(half)),
467 ).or_refuse(KernelStage::Fragment, "make_line")?,
468 ])
469}
470
471/// The section arc on the FAR side of the axis (`x ≤ 0`), closed by the same
472/// axis chord. It already lies in the half-plane at azimuth π, so revolving it
473/// through the same `+Θ` carries it over `[π, π+Θ]` — where the section's far
474/// side really does sweep.
475fn far_profile(plan: &SweptEnvelope, section_centre: Vec3) -> Result<Vec<NurbsCurve>, KernelRefusal> {
476 use std::f64::consts::TAU;
477 let (u, w) = (plan.radial, plan.axis);
478 let phi = (-plan.major / plan.minor).acos();
479 let half = plan.chord_half_length();
480 Ok(vec![
481 make_arc(section_centre, u, w, plan.minor, phi, TAU - phi).or_refuse(KernelStage::Fragment, "make_arc")?,
482 make_line(
483 plan.centre.sub(w.scale(half)),
484 plan.centre.add(w.scale(half)),
485 ).or_refuse(KernelStage::Fragment, "make_line")?,
486 ])
487}
488
489/// Revolve one lobe's profile and put it through the soundness acceptance — the
490/// same gate the loft's exit applies, because the whole point of this lane is
491/// that the loft's answer here passes through itself.
492fn revolve_lobe(
493 profile: &[NurbsCurve],
494 centre: Vec3,
495 axis: Vec3,
496 turn: f64,
497) -> Result<BrepSolid, KernelRefusal> {
498 // The revolve is still a stringly builder: its text is kept and classed
499 // Internal here, at the consuming site.
500 let solid = crate::revolve_profile_brep(profile, centre, axis, turn).map_err(|error| {
501 KernelRefusal::internal(
502 KernelStage::Fragment,
503 "revolve",
504 format!("{SWEEP_ENVELOPE_REFUSAL} could not be revolved: {error}"),
505 )
506 })?;
507 crate::accept_sound(solid, "sweepSolid")
508}
509
510/// The swept union as BODIES — the entry for a caller that can take more than
511/// one, which is what a partial turn past `ρκ = 1` really sweeps.
512///
513/// Refuses by name, quoting the measurement, when the configuration is not the
514/// one this lane builds.
515pub fn sweep_envelope_bodies(
516 profile: &[NurbsCurve],
517 path: &SweepPath,
518 placement_mode: SectionPlacement,
519) -> Result<Vec<BrepSolid>, KernelRefusal> {
520 match recognize_swept_envelope(profile, path, placement_mode)? {
521 Ok(plan) => build_swept_envelope(&plan),
522 Err(reason) => Err(KernelRefusal::unsupported(
523 KernelStage::Classify,
524 "envelope_configuration",
525 format!(
526 "{SWEEP_ENVELOPE_REFUSAL} trimmed at its own self-intersection is built for a CIRCLE \
527 carried along one circular planar arc, and this sweep is not that: {reason}. Nothing \
528 else has a closed form to gate the trim with, so it is refused rather than \
529 approximated"
530 ),
531 )),
532 }
533}