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
210/// Loft corresponding edges of closed sections. For two sections, exact
211/// degree elevation and knot insertion reconcile mixed line/arc representations
212/// and the ruled skins retain both boundaries' rational weights. Multi-section
213/// interpolation requires matching degrees, knots and weights.
214pub fn loft_profile_brep(input_sections: &[Vec<NurbsCurve>]) -> Result<BrepSolid, KernelRefusal> {
215    loft_profile_brep_core(input_sections, None)
216}
217
218/// §5.8 loft with END TANGENCY: the skin leaves the first section along
219/// `start_direction` and arrives at the last along `end_direction` (unit
220/// directions; each interpolation column scales them by its own chord length,
221/// the standard magnitude that keeps the v-parametrization well conditioned).
222/// Exact by construction — the column interpolant reproduces the prescribed
223/// end derivatives.
224pub fn loft_profile_brep_tangent(
225    input_sections: &[Vec<NurbsCurve>],
226    start_direction: Vec3,
227    end_direction: Vec3,
228) -> Result<BrepSolid, KernelRefusal> {
229    let start = start_direction.normalized().map_err(|_| {
230        KernelRefusal::input(
231            KernelStage::Collect,
232            "start_tangent",
233            "loftSolid: start tangent must be a nonzero direction",
234        )
235    })?;
236    let end = end_direction.normalized().map_err(|_| {
237        KernelRefusal::input(
238            KernelStage::Collect,
239            "end_tangent",
240            "loftSolid: end tangent must be a nonzero direction",
241        )
242    })?;
243    loft_profile_brep_core(input_sections, Some((start, end)))
244}
245
246fn loft_profile_brep_core(
247    input_sections: &[Vec<NurbsCurve>],
248    end_tangents: Option<(Vec3, Vec3)>,
249) -> Result<BrepSolid, KernelRefusal> {
250    let mut profile = super::ConstructionProfile::new("loft");
251    let tolerance = 1e-6;
252    let section_count = input_sections.len();
253    if section_count < 2 {
254        return Err(KernelRefusal::input(
255            KernelStage::Collect,
256            "section_count",
257            "loftSolid: need at least 2 sections",
258        ));
259    }
260    let mut sections = input_sections.to_vec();
261    // A two-section loft is a ruled rational patch: unlike the interpolated
262    // multi-station/tangent paths it can retain different boundary weights.
263    let ruled = section_count == 2 && end_tangents.is_none();
264    let curve_count = if ruled {
265        super::validation::validate_section_loops(&sections, tolerance, "loftSolid")?
266    } else {
267        validate_sections(&sections, tolerance, "loftSolid", true)?
268    };
269
270    let first_frame = section_frame(&sections[0], tolerance)?;
271    let last_frame = section_frame(&sections[section_count - 1], tolerance)?;
272    if !first_frame.planar || !last_frame.planar {
273        return Err(KernelRefusal::unsupported(
274            KernelStage::Classify,
275            "planar_end_sections",
276            "loftSolid: end sections must be planar",
277        ));
278    }
279    // Ends in the same place put the two caps on top of each other whatever the
280    // run does in between; say that before blaming a section plane below.
281    last_frame
282        .centroid
283        .sub(first_frame.centroid)
284        .normalized()
285        .map_err(|_| {
286            KernelRefusal::input(
287                KernelStage::Classify,
288                "coincident_ends",
289                "loftSolid: end sections coincide",
290            )
291        })?;
292    // Each cap must face away from its own inward advance direction; a turning
293    // loft can leave one end edge-on even when the other end is valid.
294    let start_advance = advance_from(
295        &sections,
296        first_frame.centroid,
297        first_frame.normal,
298        1..section_count,
299        tolerance,
300    )?
301    .ok_or_else(|| {
302        KernelRefusal::input(
303            KernelStage::Classify,
304            "start_in_plane",
305            "loftSolid: the loft runs inside its START section's plane — every later section \
306             lies in it, so the start cap would be a sliver rather than a face",
307        )
308    })?;
309    // Walking inward from the FAR end measures backwards, so negate it: both
310    // directions point the way the loft runs, start to finish.
311    let finish_advance = advance_from(
312        &sections,
313        last_frame.centroid,
314        last_frame.normal,
315        (0..section_count - 1).rev(),
316        tolerance,
317    )?
318    .map(|step| step.scale(-1.0))
319    .ok_or_else(|| {
320        KernelRefusal::input(
321            KernelStage::Classify,
322            "end_in_plane",
323            "loftSolid: the loft runs inside its END section's plane — every earlier section \
324             lies in it, so the end cap would be a sliver rather than a face",
325        )
326    })?;
327    let first_normal = if first_frame.normal.dot(start_advance) >= 0.0 {
328        first_frame.normal
329    } else {
330        first_frame.normal.scale(-1.0)
331    };
332    let x_axis = first_normal
333        .perpendicular()
334        .or_refuse(KernelStage::Classify, "section_frame")?;
335    let y_axis = first_normal
336        .cross(x_axis)
337        .normalized()
338        .or_refuse(KernelStage::Classify, "section_frame")?;
339    // Winding is normalized PER SECTION against this one frame, not decided once
340    // from section 0 and applied to every section.
341    //
342    // The skin rails control point `k` of curve `i` of one section to the same
343    // `(i, k)` of the next, so two sections traversed OPPOSITE ways rail corner
344    // to opposite corner and the skin twists into a bow-tie. Deciding one flip
345    // from section 0 and applying it to all of them cannot see that: it reverses
346    // them together, which preserves the disagreement exactly.
347    //
348    // Measured before this changed (`examples/feature_refusal_census_probe.rs`,
349    // `GATE loft_profile_brep.opposed_winding`): two 2x2 squares 5 apart, wound
350    // opposite ways, lofted to a solid of **volume 0 that passes `validate()`**
351    // — a silently-wrong result, not a refusal. The shipping LOFT feature
352    // reached it with two ordinary sketches on opposed planes.
353    //
354    // The comparison is CHAIN-RELATIVE — each section against its PREDECESSOR,
355    // not against section 0.
356    //
357    // Measuring every section against section 0's frame looks equivalent and is
358    // not: a frame-guided loft rotates each station's plane with the guide, so
359    // on a bend past 90 degrees a station's projected area goes negative purely
360    // because its plane has turned (cos > 90 deg < 0), and reversing it un-does
361    // the guide's own rotation. Measured on a 150-degree arc: volume 17.5685
362    // against a Pappus-exact 104.7198, a 83 % error, where the predecessor rule
363    // is exact.
364    //
365    // Consecutive stations of any sane loft turn far less than 90 degrees, so
366    // the predecessor comparison leaves a bend alone, while a genuinely opposed
367    // pair still flips. It also removes the knife edge at exactly 90 degrees,
368    // where a fixed-frame projection is float noise.
369    //
370    // `section_frame(..).normal` is the Newell normal, whose ORIENTATION already
371    // encodes the traversal direction; only its sign is read. Section 0 keeps
372    // the original rule verbatim, so its behaviour — and the side-face
373    // permutation below, which keys off section 0 alone — is byte-identical.
374    let mut section_reversed = Vec::with_capacity(section_count);
375    section_reversed.push(
376        profile_area(&sections[0], first_frame.centroid, x_axis, y_axis)
377            .or_refuse(KernelStage::Classify, "profile_area")?
378            < 0.0,
379    );
380    let mut previous_normal = if section_reversed[0] {
381        first_frame.normal.scale(-1.0)
382    } else {
383        first_frame.normal
384    };
385    for section in sections.iter().skip(1) {
386        let normal = section_frame(section, tolerance)?.normal;
387        let flip = normal.dot(previous_normal) < 0.0;
388        section_reversed.push(flip);
389        previous_normal = if flip { normal.scale(-1.0) } else { normal };
390    }
391    // Side faces are named by the FIRST section's input-curve order, so the
392    // face permutation below still keys off section 0 alone.
393    let reversed_winding = section_reversed[0];
394    for (index, flip) in section_reversed.into_iter().enumerate() {
395        if flip {
396            sections[index] = reverse_section(&sections[index])?;
397        }
398    }
399
400    if ruled {
401        // Match AFTER winding normalization: reversing a loop changes which
402        // curves correspond. Degree elevation and knot insertion preserve the
403        // exact line/arc geometry; no sampling or refitting is involved.
404        for curve_index in 0..curve_count {
405            let family = [
406                sections[0][curve_index].clone(),
407                sections[1][curve_index].clone(),
408            ];
409            // Preserve existing parameter domains when no conversion is needed.
410            if family[0].degree == family[1].degree
411                && family[0].knots == family[1].knots
412                && family[0].control_points.len() == family[1].control_points.len()
413            {
414                continue;
415            }
416            let (compatible, _) = crate::coons::compat::make_family_compatible(&family, 1)
417                .or_refuse(KernelStage::Refine, "section_compatibility")?;
418            for (section, curve) in sections.iter_mut().zip(compatible) {
419                section[curve_index] = curve;
420            }
421        }
422    }
423
424    profile.lap("profile");
425    let mut parameters = vec![0.0; section_count];
426    let mut accumulated = vec![0.0; section_count];
427    let mut columns = 0usize;
428    for curve_index in 0..curve_count {
429        for control_index in 0..sections[0][curve_index].control_points.len() {
430            let mut total = 0.0;
431            let mut chords = vec![0.0; section_count];
432            for section_index in 1..section_count {
433                let previous = sections[section_index - 1][curve_index].control_points
434                    [control_index]
435                    .point()
436                    .or_refuse(KernelStage::Refine, "control_point")?;
437                let current = sections[section_index][curve_index].control_points[control_index]
438                    .point()
439                    .or_refuse(KernelStage::Refine, "control_point")?;
440                total += current.sub(previous).length();
441                chords[section_index] = total;
442            }
443            if total <= tolerance {
444                continue;
445            }
446            for section_index in 0..section_count {
447                accumulated[section_index] += chords[section_index] / total;
448            }
449            columns += 1;
450        }
451    }
452    if columns == 0 {
453        return Err(KernelRefusal::input(
454            KernelStage::Classify,
455            "coincident_sections",
456            "loftSolid: sections coincide",
457        ));
458    }
459    for index in 0..section_count {
460        parameters[index] = accumulated[index] / columns as f64;
461    }
462    parameters[0] = 0.0;
463    parameters[section_count - 1] = 1.0;
464    if parameters.windows(2).any(|pair| pair[1] <= pair[0] + 1e-9) {
465        return Err(KernelRefusal::input(
466            KernelStage::Classify,
467            "section_order",
468            "loftSolid: sections are not strictly ordered",
469        ));
470    }
471    // Tangent lofts always interpolate cubically — the end-derivative rows
472    // need the two extra control points even for a 2-section Hermite loft.
473    let degree_v = if end_tangents.is_some() {
474        3
475    } else {
476        3usize.min(section_count - 1)
477    };
478    let mut skins = Vec::with_capacity(curve_count);
479    for curve_index in 0..curve_count {
480        let reference = &sections[0][curve_index];
481        if ruled {
482            // Interpolate HOMOGENEOUS controls, including each end's own
483            // weights. Copying the first end's weights onto the second would
484            // turn an exact circular arc into a different boundary curve.
485            let grid = reference
486                .control_points
487                .iter()
488                .zip(&sections[1][curve_index].control_points)
489                .map(|(&start, &end)| vec![start, end])
490                .collect();
491            skins.push(
492                NurbsSurface::new(
493                    reference.degree,
494                    1,
495                    reference.knots.clone(),
496                    vec![0.0, 0.0, 1.0, 1.0],
497                    grid,
498                )
499                .or_refuse(KernelStage::Fragment, "skin")?,
500            );
501            continue;
502        }
503        let mut grid = Vec::with_capacity(reference.control_points.len());
504        let mut knots_v = Vec::new();
505        for control_index in 0..reference.control_points.len() {
506            let points = sections
507                .iter()
508                .map(|section| section[curve_index].control_points[control_index].point())
509                .collect::<Result<Vec<_>, String>>()
510                .or_refuse(KernelStage::Refine, "control_point")?;
511            let interpolated = match end_tangents {
512                None => interpolate_curve(&points, degree_v, &parameters)
513                    .or_refuse(KernelStage::Refine, "interpolate")?,
514                Some((start_direction, end_direction)) => {
515                    let chord: f64 = points
516                        .windows(2)
517                        .map(|pair| pair[1].sub(pair[0]).length())
518                        .sum();
519                    // A column whose stations coincide still needs a usable
520                    // tangent magnitude; fall back to the section spacing.
521                    let magnitude = if chord > tolerance { chord } else { 1.0 };
522                    crate::interpolate_curve_with_end_tangents(
523                        &points,
524                        &parameters,
525                        start_direction.scale(magnitude),
526                        end_direction.scale(magnitude),
527                    )
528                    .or_refuse(KernelStage::Refine, "interpolate_tangent")?
529                }
530            };
531            knots_v = interpolated.knots.clone();
532            let weight = reference.control_points[control_index].w;
533            grid.push(
534                interpolated
535                    .control_points
536                    .iter()
537                    .map(|point| Vec4::from_point(point.point().unwrap(), weight))
538                    .collect(),
539            );
540        }
541        skins.push(NurbsSurface::new(
542            reference.degree,
543            degree_v,
544            reference.knots.clone(),
545            knots_v,
546            grid,
547        )
548        .or_refuse(KernelStage::Fragment, "skin")?);
549    }
550
551    profile.lap("surface");
552    let bottom = &sections[0];
553    let top = &sections[section_count - 1];
554    let bottom_points = closed_points(bottom, tolerance)?;
555    let top_points = closed_points(top, tolerance)?;
556    let mut vertices = Vec::with_capacity(2 * curve_count);
557    for (index, point) in bottom_points.iter().chain(&top_points).enumerate() {
558        vertices.push(VertexRecord {
559            id: index as u64 + 1,
560            point: *point,
561        });
562    }
563    let mut edges = Vec::with_capacity(3 * curve_count);
564    let mut bottom_edge_ids = Vec::new();
565    let mut top_edge_ids = Vec::new();
566    let mut vertical_edge_ids = Vec::new();
567    for index in 0..curve_count {
568        let [start, end] = bottom[index]
569            .domain()
570            .or_refuse(KernelStage::Fragment, "domain")?;
571        // The TOP curve's own range, which is NOT the bottom's once the winding
572        // normalization has reversed one section's curve ORDER: `reverse_section`
573        // reverses the order and `reversed()` preserves each curve's domain, so
574        // `top[i]` becomes a curve that lived on a different parameter span.
575        // Stamping the bottom's span on it made `coedge_sample` evaluate the top
576        // curve outside its own domain — pinned at an end while the skin swept
577        // the real arc — which on a circular section split into two half-arcs is
578        // a deviation of exactly the diameter.
579        let [top_start, top_end] = top[index]
580            .domain()
581            .or_refuse(KernelStage::Fragment, "domain")?;
582        let bottom_id = 10 + index as u64;
583        let top_id = 10 + curve_count as u64 + index as u64;
584        let vertical_id = 10 + 2 * curve_count as u64 + index as u64;
585        bottom_edge_ids.push(bottom_id);
586        top_edge_ids.push(top_id);
587        vertical_edge_ids.push(vertical_id);
588        edges.push(EdgeRecord {
589            id: bottom_id,
590            curve: bottom[index].clone(),
591            t0: start,
592            t1: end,
593            start_vertex_id: index as u64 + 1,
594            end_vertex_id: ((index + 1) % curve_count) as u64 + 1,
595            degenerate: false,
596            name: None,
597        });
598        edges.push(EdgeRecord {
599            id: top_id,
600            curve: top[index].clone(),
601            t0: top_start,
602            t1: top_end,
603            start_vertex_id: (curve_count + index) as u64 + 1,
604            end_vertex_id: (curve_count + (index + 1) % curve_count) as u64 + 1,
605            degenerate: false,
606            name: None,
607        });
608        let vertical = skins[index]
609            .iso_curve_u(start)
610            .or_refuse(KernelStage::Fragment, "iso_curve")?;
611        let [v_start, v_end] = vertical
612            .domain()
613            .or_refuse(KernelStage::Fragment, "domain")?;
614        edges.push(EdgeRecord {
615            id: vertical_id,
616            curve: vertical,
617            t0: v_start,
618            t1: v_end,
619            start_vertex_id: index as u64 + 1,
620            end_vertex_id: (curve_count + index) as u64 + 1,
621            degenerate: false,
622            name: None,
623        });
624    }
625
626    profile.lap("edges");
627    let mut next_id = 1000u64;
628    let mut faces = Vec::with_capacity(curve_count + 2);
629    for index in 0..curve_count {
630        let [start, end] = bottom[index]
631            .domain()
632            .or_refuse(KernelStage::Fragment, "domain")?;
633        let coedges = vec![
634            CoedgeRecord {
635                id: next_id,
636                edge_id: bottom_edge_ids[index],
637                forward: true,
638                pcurve: parameter_line(start, 0.0, end, 0.0)
639                    .or_refuse(KernelStage::Fragment, "pcurve")?,
640            },
641            CoedgeRecord {
642                id: next_id + 1,
643                edge_id: vertical_edge_ids[(index + 1) % curve_count],
644                forward: true,
645                pcurve: parameter_line(end, 0.0, end, 1.0)
646                    .or_refuse(KernelStage::Fragment, "pcurve")?,
647            },
648            CoedgeRecord {
649                id: next_id + 2,
650                edge_id: top_edge_ids[index],
651                forward: false,
652                pcurve: parameter_line(end, 1.0, start, 1.0)
653                    .or_refuse(KernelStage::Fragment, "pcurve")?,
654            },
655            CoedgeRecord {
656                id: next_id + 3,
657                edge_id: vertical_edge_ids[index],
658                forward: false,
659                pcurve: parameter_line(start, 1.0, start, 0.0)
660                    .or_refuse(KernelStage::Fragment, "pcurve")?,
661            },
662        ];
663        next_id += 4;
664        let loop_id = next_id;
665        next_id += 1;
666        let face_id = next_id;
667        next_id += 1;
668        faces.push(FaceRecord {
669            id: face_id,
670            surface: skins[index].clone(),
671            same_sense: true,
672            loops: vec![LoopRecord {
673                id: loop_id,
674                coedges,
675            }],
676            name: None,
677        });
678    }
679    if reversed_winding {
680        // The winding normalization reversed the section curve order above.
681        // Callers stamp side-face names by INPUT-curve order (the extrude
682        // and revolve wall-naming permutation, loft edition) — emit side
683        // faces in input order.
684        faces.reverse();
685    }
686    faces.push(cap_face(
687        bottom,
688        &bottom_edge_ids,
689        &first_frame,
690        start_advance,
691        false,
692        &mut next_id,
693    )?);
694    faces.push(cap_face(
695        top,
696        &top_edge_ids,
697        &last_frame,
698        finish_advance,
699        true,
700        &mut next_id,
701    )?);
702    let solid = BrepSolid {
703        mass_properties_cache: Default::default(),
704        cells: Default::default(),
705        id: next_id + 1,
706        vertices,
707        edges,
708        shells: vec![ShellRecord { id: next_id, faces }],
709        genus: 0,
710        kind: crate::BodyKind::Solid,
711    };
712    profile.lap("topology_pcurves");
713    let issues = solid.validate();
714    profile.lap("validation");
715    if !issues.is_empty() {
716        return Err(KernelRefusal::internal(
717            KernelStage::Validate,
718            "validate",
719            format!(
720                "Rust loft builder produced invalid topology: {issues:?}"
721            ),
722        ));
723    }
724    // `validate()` is an INCIDENCE test: a loft whose walls pass through each
725    // other — sections that cross, a run tighter than the profile's own
726    // half-width — satisfies every one of its checks. This is the soundness
727    // question asked at the lane's single exit, repaired where it can be and
728    // refused by name where it cannot. Every path sweep ends here too.
729    let accepted = crate::accept_sound(solid, "loftSolid");
730    profile.lap("acceptance");
731    accepted
732}