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}