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