Skip to main content

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