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