Skip to main content

brep_kernel/construction/loft_topology/
basic.rs

1use super::*;
2
3#[derive(Clone)]
4struct SectionFrame {
5    normal: Vec3,
6    centroid: Vec3,
7    samples: Vec<Vec3>,
8    planar: bool,
9}
10
11pub(super) fn closed_points(
12    curves: &[NurbsCurve],
13    tolerance: f64,
14) -> Result<Vec<Vec3>, KernelRefusal> {
15    if curves.len() < 2 {
16        return Err(KernelRefusal::input(
17            KernelStage::Collect,
18            "section_curve_count",
19            "loftSolid: a section needs at least 2 curves",
20        ));
21    }
22    let mut points = Vec::with_capacity(curves.len());
23    for (index, curve) in curves.iter().enumerate() {
24        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
25        let next = &curves[(index + 1) % curves.len()];
26        let next_start = next.domain().or_refuse(KernelStage::Collect, "domain")?[0];
27        if curve
28            .evaluate(end)
29            .or_refuse(KernelStage::Collect, "evaluate")?
30            .sub(
31                next.evaluate(next_start)
32                    .or_refuse(KernelStage::Collect, "evaluate")?,
33            )
34            .length()
35            > tolerance
36        {
37            return Err(KernelRefusal::input(
38                KernelStage::Collect,
39                "open_section",
40                format!("loftSolid: section is open at curve {index}"),
41            ));
42        }
43        points.push(
44            curve
45                .evaluate(start)
46                .or_refuse(KernelStage::Collect, "evaluate")?,
47        );
48    }
49    Ok(points)
50}
51
52fn section_frame(curves: &[NurbsCurve], tolerance: f64) -> Result<SectionFrame, KernelRefusal> {
53    let mut samples = Vec::new();
54    for curve in curves {
55        let [start, end] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
56        for index in 0..16 {
57            samples.push(
58                curve
59                    .evaluate(start + (end - start) * index as f64 / 16.0)
60                    .or_refuse(KernelStage::Classify, "evaluate")?,
61            );
62        }
63    }
64    let mut normal = crate::polygon::newell_normal(&samples);
65    let mut centroid = samples.iter().fold(Vec3::default(), |sum, &point| sum.add(point));
66    // A zero Newell normal is a section without area — the caller's loop, not a
67    // solve, so it is an input refusal carrying the vector helper's own text.
68    normal = normal
69        .normalized()
70        .or_input(KernelStage::Classify, "section_normal")?;
71    centroid = centroid.scale(1.0 / samples.len() as f64);
72    let planar = samples
73        .iter()
74        .all(|point| point.sub(samples[0]).dot(normal).abs() <= tolerance * 100.0);
75    Ok(SectionFrame {
76        normal,
77        centroid,
78        samples,
79        planar,
80    })
81}
82
83/// How far off an end section's plane a step has to lean before it may say which
84/// side of that section the loft's material is on. `0.1` — the same band
85/// `extrude_profile_brep` refuses a sliver at, and the same one the sweep's own
86/// per-station guard uses.
87const CAP_ADVANCE_MIN: f64 = 0.1;
88
89/// Find the inward advance direction at an end section, walking nearest first.
90/// Skip centroids in the end plane until `|step·normal| / |step|` reaches
91/// `CAP_ADVANCE_MIN`. This handles turning lofts and guides that initially
92/// travel sideways; an end-to-end chord alone can misorient their caps.
93/// Return `None` if the entire run stays in the end plane.
94fn advance_from(
95    sections: &[Vec<NurbsCurve>],
96    anchor: Vec3,
97    normal: Vec3,
98    order: impl Iterator<Item = usize>,
99    tolerance: f64,
100) -> Result<Option<Vec3>, KernelRefusal> {
101    for index in order {
102        let step = section_frame(&sections[index], tolerance)?
103            .centroid
104            .sub(anchor);
105        let length = step.length();
106        if length > tolerance && (step.dot(normal) / length).abs() >= CAP_ADVANCE_MIN {
107            return Ok(Some(step.scale(1.0 / length)));
108        }
109    }
110    Ok(None)
111}
112
113fn reverse_section(curves: &[NurbsCurve]) -> Result<Vec<NurbsCurve>, KernelRefusal> {
114    curves
115        .iter()
116        .rev()
117        .map(NurbsCurve::reversed)
118        .collect::<Result<Vec<_>, String>>()
119        .or_refuse(KernelStage::Classify, "reverse_section")
120}
121
122/// One end cap. `advance` is the direction the loft RUNS THROUGH this cap —
123/// measured at this end, not end to end (see `advance_from`) — and it orients
124/// the cap plane: `outward` then says whether the face's own normal follows it
125/// (the finish cap) or opposes it (the start cap).
126fn cap_face(
127    curves: &[NurbsCurve],
128    edge_ids: &[u64],
129    frame: &SectionFrame,
130    advance: Vec3,
131    outward: bool,
132    next_id: &mut u64,
133) -> Result<FaceRecord, KernelRefusal> {
134    let normal = if frame.normal.dot(advance) >= 0.0 {
135        frame.normal
136    } else {
137        frame.normal.scale(-1.0)
138    };
139    let x_axis = normal
140        .perpendicular()
141        .or_refuse(KernelStage::Fragment, "cap_frame")?;
142    let y_axis = normal
143        .cross(x_axis)
144        .normalized()
145        .or_refuse(KernelStage::Fragment, "cap_frame")?;
146    let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
147    let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
148    for point in &frame.samples {
149        let delta = point.sub(frame.centroid);
150        min_x = min_x.min(delta.dot(x_axis));
151        max_x = max_x.max(delta.dot(x_axis));
152        min_y = min_y.min(delta.dot(y_axis));
153        max_y = max_y.max(delta.dot(y_axis));
154    }
155    let padding = (max_x - min_x).max(max_y - min_y) * 0.05 + 1e-6;
156    let origin = frame
157        .centroid
158        .add(x_axis.scale(min_x - padding))
159        .add(y_axis.scale(min_y - padding));
160    let surface = make_plane(
161        origin,
162        x_axis,
163        y_axis,
164        max_x - min_x + 2.0 * padding,
165        max_y - min_y + 2.0 * padding,
166    )
167    .or_refuse(KernelStage::Fragment, "cap_plane")?;
168    let mut coedges = Vec::with_capacity(curves.len());
169    if outward {
170        for (index, curve) in curves.iter().enumerate() {
171            coedges.push(CoedgeRecord {
172                id: *next_id,
173                edge_id: edge_ids[index],
174                forward: true,
175                pcurve: curve_to_plane_parameters(curve, origin, x_axis, y_axis)
176                    .or_refuse(KernelStage::Fragment, "cap_pcurve")?,
177            });
178            *next_id += 1;
179        }
180    } else {
181        for index in (0..curves.len()).rev() {
182            coedges.push(CoedgeRecord {
183                id: *next_id,
184                edge_id: edge_ids[index],
185                forward: false,
186                pcurve: curve_to_plane_parameters(&curves[index], origin, x_axis, y_axis)
187                    .or_refuse(KernelStage::Fragment, "cap_pcurve")?
188                    .reversed()
189                    .or_refuse(KernelStage::Fragment, "cap_pcurve")?,
190            });
191            *next_id += 1;
192        }
193    }
194    let loop_id = *next_id;
195    *next_id += 1;
196    let face_id = *next_id;
197    *next_id += 1;
198    Ok(FaceRecord {
199        id: face_id,
200        surface,
201        same_sense: outward,
202        loops: vec![LoopRecord {
203            id: loop_id,
204            coedges,
205        }],
206        name: None,
207    })
208}
209
210pub fn loft_profile_brep(input_sections: &[Vec<NurbsCurve>]) -> Result<BrepSolid, KernelRefusal> {
211    loft_profile_brep_core(input_sections, None)
212}
213
214/// §5.8 loft with END TANGENCY: the skin leaves the first section along
215/// `start_direction` and arrives at the last along `end_direction` (unit
216/// directions; each interpolation column scales them by its own chord length,
217/// the standard magnitude that keeps the v-parametrization well conditioned).
218/// Exact by construction — the column interpolant reproduces the prescribed
219/// end derivatives.
220pub fn loft_profile_brep_tangent(
221    input_sections: &[Vec<NurbsCurve>],
222    start_direction: Vec3,
223    end_direction: Vec3,
224) -> Result<BrepSolid, KernelRefusal> {
225    let start = start_direction.normalized().map_err(|_| {
226        KernelRefusal::input(
227            KernelStage::Collect,
228            "start_tangent",
229            "loftSolid: start tangent must be a nonzero direction",
230        )
231    })?;
232    let end = end_direction.normalized().map_err(|_| {
233        KernelRefusal::input(
234            KernelStage::Collect,
235            "end_tangent",
236            "loftSolid: end tangent must be a nonzero direction",
237        )
238    })?;
239    loft_profile_brep_core(input_sections, Some((start, end)))
240}
241
242fn loft_profile_brep_core(
243    input_sections: &[Vec<NurbsCurve>],
244    end_tangents: Option<(Vec3, Vec3)>,
245) -> Result<BrepSolid, KernelRefusal> {
246    let tolerance = 1e-6;
247    let section_count = input_sections.len();
248    if section_count < 2 {
249        return Err(KernelRefusal::input(
250            KernelStage::Collect,
251            "section_count",
252            "loftSolid: need at least 2 sections",
253        ));
254    }
255    let mut sections = input_sections.to_vec();
256    let curve_count = validate_sections(&sections, tolerance, "loftSolid", true)?;
257
258    let first_frame = section_frame(&sections[0], tolerance)?;
259    let last_frame = section_frame(&sections[section_count - 1], tolerance)?;
260    if !first_frame.planar || !last_frame.planar {
261        return Err(KernelRefusal::unsupported(
262            KernelStage::Classify,
263            "planar_end_sections",
264            "loftSolid: end sections must be planar",
265        ));
266    }
267    // Ends in the same place put the two caps on top of each other whatever the
268    // run does in between; say that before blaming a section plane below.
269    last_frame
270        .centroid
271        .sub(first_frame.centroid)
272        .normalized()
273        .map_err(|_| {
274            KernelRefusal::input(
275                KernelStage::Classify,
276                "coincident_ends",
277                "loftSolid: end sections coincide",
278            )
279        })?;
280    // Each cap must face away from its own inward advance direction; a turning
281    // loft can leave one end edge-on even when the other end is valid.
282    let start_advance = advance_from(
283        &sections,
284        first_frame.centroid,
285        first_frame.normal,
286        1..section_count,
287        tolerance,
288    )?
289    .ok_or_else(|| {
290        KernelRefusal::input(
291            KernelStage::Classify,
292            "start_in_plane",
293            "loftSolid: the loft runs inside its START section's plane — every later section \
294             lies in it, so the start cap would be a sliver rather than a face",
295        )
296    })?;
297    // Walking inward from the FAR end measures backwards, so negate it: both
298    // directions point the way the loft runs, start to finish.
299    let finish_advance = advance_from(
300        &sections,
301        last_frame.centroid,
302        last_frame.normal,
303        (0..section_count - 1).rev(),
304        tolerance,
305    )?
306    .map(|step| step.scale(-1.0))
307    .ok_or_else(|| {
308        KernelRefusal::input(
309            KernelStage::Classify,
310            "end_in_plane",
311            "loftSolid: the loft runs inside its END section's plane — every earlier section \
312             lies in it, so the end cap would be a sliver rather than a face",
313        )
314    })?;
315    let first_normal = if first_frame.normal.dot(start_advance) >= 0.0 {
316        first_frame.normal
317    } else {
318        first_frame.normal.scale(-1.0)
319    };
320    let x_axis = first_normal
321        .perpendicular()
322        .or_refuse(KernelStage::Classify, "section_frame")?;
323    let y_axis = first_normal
324        .cross(x_axis)
325        .normalized()
326        .or_refuse(KernelStage::Classify, "section_frame")?;
327    // Winding is normalized PER SECTION against this one frame, not decided once
328    // from section 0 and applied to every section.
329    //
330    // The skin rails control point `k` of curve `i` of one section to the same
331    // `(i, k)` of the next, so two sections traversed OPPOSITE ways rail corner
332    // to opposite corner and the skin twists into a bow-tie. Deciding one flip
333    // from section 0 and applying it to all of them cannot see that: it reverses
334    // them together, which preserves the disagreement exactly.
335    //
336    // Measured before this changed (`examples/feature_refusal_census_probe.rs`,
337    // `GATE loft_profile_brep.opposed_winding`): two 2x2 squares 5 apart, wound
338    // opposite ways, lofted to a solid of **volume 0 that passes `validate()`**
339    // — a silently-wrong result, not a refusal. The shipping LOFT feature
340    // reached it with two ordinary sketches on opposed planes.
341    //
342    // The comparison is CHAIN-RELATIVE — each section against its PREDECESSOR,
343    // not against section 0.
344    //
345    // Measuring every section against section 0's frame looks equivalent and is
346    // not: a frame-guided loft rotates each station's plane with the guide, so
347    // on a bend past 90 degrees a station's projected area goes negative purely
348    // because its plane has turned (cos > 90 deg < 0), and reversing it un-does
349    // the guide's own rotation. Measured on a 150-degree arc: volume 17.5685
350    // against a Pappus-exact 104.7198, a 83 % error, where the predecessor rule
351    // is exact.
352    //
353    // Consecutive stations of any sane loft turn far less than 90 degrees, so
354    // the predecessor comparison leaves a bend alone, while a genuinely opposed
355    // pair still flips. It also removes the knife edge at exactly 90 degrees,
356    // where a fixed-frame projection is float noise.
357    //
358    // `section_frame(..).normal` is the Newell normal, whose ORIENTATION already
359    // encodes the traversal direction; only its sign is read. Section 0 keeps
360    // the original rule verbatim, so its behaviour — and the side-face
361    // permutation below, which keys off section 0 alone — is byte-identical.
362    let mut section_reversed = Vec::with_capacity(section_count);
363    section_reversed.push(
364        profile_area(&sections[0], first_frame.centroid, x_axis, y_axis)
365            .or_refuse(KernelStage::Classify, "profile_area")?
366            < 0.0,
367    );
368    let mut previous_normal = if section_reversed[0] {
369        first_frame.normal.scale(-1.0)
370    } else {
371        first_frame.normal
372    };
373    for section in sections.iter().skip(1) {
374        let normal = section_frame(section, tolerance)?.normal;
375        let flip = normal.dot(previous_normal) < 0.0;
376        section_reversed.push(flip);
377        previous_normal = if flip { normal.scale(-1.0) } else { normal };
378    }
379    // Side faces are named by the FIRST section's input-curve order, so the
380    // face permutation below still keys off section 0 alone.
381    let reversed_winding = section_reversed[0];
382    for (index, flip) in section_reversed.into_iter().enumerate() {
383        if flip {
384            sections[index] = reverse_section(&sections[index])?;
385        }
386    }
387
388    let mut parameters = vec![0.0; section_count];
389    let mut accumulated = vec![0.0; section_count];
390    let mut columns = 0usize;
391    for curve_index in 0..curve_count {
392        for control_index in 0..sections[0][curve_index].control_points.len() {
393            let mut total = 0.0;
394            let mut chords = vec![0.0; section_count];
395            for section_index in 1..section_count {
396                let previous = sections[section_index - 1][curve_index].control_points
397                    [control_index]
398                    .point()
399                    .or_refuse(KernelStage::Refine, "control_point")?;
400                let current = sections[section_index][curve_index].control_points[control_index]
401                    .point()
402                    .or_refuse(KernelStage::Refine, "control_point")?;
403                total += current.sub(previous).length();
404                chords[section_index] = total;
405            }
406            if total <= tolerance {
407                continue;
408            }
409            for section_index in 0..section_count {
410                accumulated[section_index] += chords[section_index] / total;
411            }
412            columns += 1;
413        }
414    }
415    if columns == 0 {
416        return Err(KernelRefusal::input(
417            KernelStage::Classify,
418            "coincident_sections",
419            "loftSolid: sections coincide",
420        ));
421    }
422    for index in 0..section_count {
423        parameters[index] = accumulated[index] / columns as f64;
424    }
425    parameters[0] = 0.0;
426    parameters[section_count - 1] = 1.0;
427    if parameters.windows(2).any(|pair| pair[1] <= pair[0] + 1e-9) {
428        return Err(KernelRefusal::input(
429            KernelStage::Classify,
430            "section_order",
431            "loftSolid: sections are not strictly ordered",
432        ));
433    }
434    // Tangent lofts always interpolate cubically — the end-derivative rows
435    // need the two extra control points even for a 2-section Hermite loft.
436    let degree_v = if end_tangents.is_some() {
437        3
438    } else {
439        3usize.min(section_count - 1)
440    };
441    let mut skins = Vec::with_capacity(curve_count);
442    for curve_index in 0..curve_count {
443        let reference = &sections[0][curve_index];
444        let mut grid = Vec::with_capacity(reference.control_points.len());
445        let mut knots_v = Vec::new();
446        for control_index in 0..reference.control_points.len() {
447            let points = sections
448                .iter()
449                .map(|section| section[curve_index].control_points[control_index].point())
450                .collect::<Result<Vec<_>, String>>()
451                .or_refuse(KernelStage::Refine, "control_point")?;
452            let interpolated = match end_tangents {
453                None => interpolate_curve(&points, degree_v, &parameters)
454                    .or_refuse(KernelStage::Refine, "interpolate")?,
455                Some((start_direction, end_direction)) => {
456                    let chord: f64 = points
457                        .windows(2)
458                        .map(|pair| pair[1].sub(pair[0]).length())
459                        .sum();
460                    // A column whose stations coincide still needs a usable
461                    // tangent magnitude; fall back to the section spacing.
462                    let magnitude = if chord > tolerance { chord } else { 1.0 };
463                    crate::interpolate_curve_with_end_tangents(
464                        &points,
465                        &parameters,
466                        start_direction.scale(magnitude),
467                        end_direction.scale(magnitude),
468                    )
469                    .or_refuse(KernelStage::Refine, "interpolate_tangent")?
470                }
471            };
472            knots_v = interpolated.knots.clone();
473            let weight = reference.control_points[control_index].w;
474            grid.push(
475                interpolated
476                    .control_points
477                    .iter()
478                    .map(|point| Vec4::from_point(point.point().unwrap(), weight))
479                    .collect(),
480            );
481        }
482        skins.push(NurbsSurface::new(
483            reference.degree,
484            degree_v,
485            reference.knots.clone(),
486            knots_v,
487            grid,
488        )
489        .or_refuse(KernelStage::Fragment, "skin")?);
490    }
491
492    let bottom = &sections[0];
493    let top = &sections[section_count - 1];
494    let bottom_points = closed_points(bottom, tolerance)?;
495    let top_points = closed_points(top, tolerance)?;
496    let mut vertices = Vec::with_capacity(2 * curve_count);
497    for (index, point) in bottom_points.iter().chain(&top_points).enumerate() {
498        vertices.push(VertexRecord {
499            id: index as u64 + 1,
500            point: *point,
501        });
502    }
503    let mut edges = Vec::with_capacity(3 * curve_count);
504    let mut bottom_edge_ids = Vec::new();
505    let mut top_edge_ids = Vec::new();
506    let mut vertical_edge_ids = Vec::new();
507    for index in 0..curve_count {
508        let [start, end] = bottom[index]
509            .domain()
510            .or_refuse(KernelStage::Fragment, "domain")?;
511        // The TOP curve's own range, which is NOT the bottom's once the winding
512        // normalization has reversed one section's curve ORDER: `reverse_section`
513        // reverses the order and `reversed()` preserves each curve's domain, so
514        // `top[i]` becomes a curve that lived on a different parameter span.
515        // Stamping the bottom's span on it made `coedge_sample` evaluate the top
516        // curve outside its own domain — pinned at an end while the skin swept
517        // the real arc — which on a circular section split into two half-arcs is
518        // a deviation of exactly the diameter.
519        let [top_start, top_end] = top[index]
520            .domain()
521            .or_refuse(KernelStage::Fragment, "domain")?;
522        let bottom_id = 10 + index as u64;
523        let top_id = 10 + curve_count as u64 + index as u64;
524        let vertical_id = 10 + 2 * curve_count as u64 + index as u64;
525        bottom_edge_ids.push(bottom_id);
526        top_edge_ids.push(top_id);
527        vertical_edge_ids.push(vertical_id);
528        edges.push(EdgeRecord {
529            id: bottom_id,
530            curve: bottom[index].clone(),
531            t0: start,
532            t1: end,
533            start_vertex_id: index as u64 + 1,
534            end_vertex_id: ((index + 1) % curve_count) as u64 + 1,
535            degenerate: false,
536            name: None,
537        });
538        edges.push(EdgeRecord {
539            id: top_id,
540            curve: top[index].clone(),
541            t0: top_start,
542            t1: top_end,
543            start_vertex_id: (curve_count + index) as u64 + 1,
544            end_vertex_id: (curve_count + (index + 1) % curve_count) as u64 + 1,
545            degenerate: false,
546            name: None,
547        });
548        let vertical = skins[index]
549            .iso_curve_u(start)
550            .or_refuse(KernelStage::Fragment, "iso_curve")?;
551        let [v_start, v_end] = vertical
552            .domain()
553            .or_refuse(KernelStage::Fragment, "domain")?;
554        edges.push(EdgeRecord {
555            id: vertical_id,
556            curve: vertical,
557            t0: v_start,
558            t1: v_end,
559            start_vertex_id: index as u64 + 1,
560            end_vertex_id: (curve_count + index) as u64 + 1,
561            degenerate: false,
562            name: None,
563        });
564    }
565
566    let mut next_id = 1000u64;
567    let mut faces = Vec::with_capacity(curve_count + 2);
568    for index in 0..curve_count {
569        let [start, end] = bottom[index]
570            .domain()
571            .or_refuse(KernelStage::Fragment, "domain")?;
572        let coedges = vec![
573            CoedgeRecord {
574                id: next_id,
575                edge_id: bottom_edge_ids[index],
576                forward: true,
577                pcurve: parameter_line(start, 0.0, end, 0.0)
578                    .or_refuse(KernelStage::Fragment, "pcurve")?,
579            },
580            CoedgeRecord {
581                id: next_id + 1,
582                edge_id: vertical_edge_ids[(index + 1) % curve_count],
583                forward: true,
584                pcurve: parameter_line(end, 0.0, end, 1.0)
585                    .or_refuse(KernelStage::Fragment, "pcurve")?,
586            },
587            CoedgeRecord {
588                id: next_id + 2,
589                edge_id: top_edge_ids[index],
590                forward: false,
591                pcurve: parameter_line(end, 1.0, start, 1.0)
592                    .or_refuse(KernelStage::Fragment, "pcurve")?,
593            },
594            CoedgeRecord {
595                id: next_id + 3,
596                edge_id: vertical_edge_ids[index],
597                forward: false,
598                pcurve: parameter_line(start, 1.0, start, 0.0)
599                    .or_refuse(KernelStage::Fragment, "pcurve")?,
600            },
601        ];
602        next_id += 4;
603        let loop_id = next_id;
604        next_id += 1;
605        let face_id = next_id;
606        next_id += 1;
607        faces.push(FaceRecord {
608            id: face_id,
609            surface: skins[index].clone(),
610            same_sense: true,
611            loops: vec![LoopRecord {
612                id: loop_id,
613                coedges,
614            }],
615            name: None,
616        });
617    }
618    if reversed_winding {
619        // The winding normalization reversed the section curve order above.
620        // Callers stamp side-face names by INPUT-curve order (the extrude
621        // and revolve wall-naming permutation, loft edition) — emit side
622        // faces in input order.
623        faces.reverse();
624    }
625    faces.push(cap_face(
626        bottom,
627        &bottom_edge_ids,
628        &first_frame,
629        start_advance,
630        false,
631        &mut next_id,
632    )?);
633    faces.push(cap_face(
634        top,
635        &top_edge_ids,
636        &last_frame,
637        finish_advance,
638        true,
639        &mut next_id,
640    )?);
641    let solid = BrepSolid {
642        mass_properties_cache: Default::default(),
643        id: next_id + 1,
644        vertices,
645        edges,
646        shells: vec![ShellRecord { id: next_id, faces }],
647        genus: 0,
648    };
649    let issues = solid.validate();
650    if !issues.is_empty() {
651        return Err(KernelRefusal::internal(
652            KernelStage::Validate,
653            "validate",
654            format!(
655                "Rust loft builder produced invalid topology: {issues:?}"
656            ),
657        ));
658    }
659    // `validate()` is an INCIDENCE test: a loft whose walls pass through each
660    // other — sections that cross, a run tighter than the profile's own
661    // half-width — satisfies every one of its checks. This is the soundness
662    // question asked at the lane's single exit, repaired where it can be and
663    // refused by name where it cannot. Every path sweep ends here too.
664    crate::accept_sound(solid, "loftSolid")
665}