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