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    let reversed_winding = profile_area(&sections[0], first_frame.centroid, x_axis, y_axis)? < 0.0;
240    if reversed_winding {
241        sections = sections
242            .iter()
243            .map(|section| reverse_section(section))
244            .collect::<Result<_, _>>()?;
245    }
246
247    let mut parameters = vec![0.0; section_count];
248    let mut accumulated = vec![0.0; section_count];
249    let mut columns = 0usize;
250    for curve_index in 0..curve_count {
251        for control_index in 0..sections[0][curve_index].control_points.len() {
252            let mut total = 0.0;
253            let mut chords = vec![0.0; section_count];
254            for section_index in 1..section_count {
255                let previous = sections[section_index - 1][curve_index].control_points
256                    [control_index]
257                    .point()?;
258                let current =
259                    sections[section_index][curve_index].control_points[control_index].point()?;
260                total += current.sub(previous).length();
261                chords[section_index] = total;
262            }
263            if total <= tolerance {
264                continue;
265            }
266            for section_index in 0..section_count {
267                accumulated[section_index] += chords[section_index] / total;
268            }
269            columns += 1;
270        }
271    }
272    if columns == 0 {
273        return Err("loftSolid: sections coincide".into());
274    }
275    for index in 0..section_count {
276        parameters[index] = accumulated[index] / columns as f64;
277    }
278    parameters[0] = 0.0;
279    parameters[section_count - 1] = 1.0;
280    if parameters.windows(2).any(|pair| pair[1] <= pair[0] + 1e-9) {
281        return Err("loftSolid: sections are not strictly ordered".into());
282    }
283    // Tangent lofts always interpolate cubically โ€” the end-derivative rows
284    // need the two extra control points even for a 2-section Hermite loft.
285    let degree_v = if end_tangents.is_some() {
286        3
287    } else {
288        3usize.min(section_count - 1)
289    };
290    let mut skins = Vec::with_capacity(curve_count);
291    for curve_index in 0..curve_count {
292        let reference = &sections[0][curve_index];
293        let mut grid = Vec::with_capacity(reference.control_points.len());
294        let mut knots_v = Vec::new();
295        for control_index in 0..reference.control_points.len() {
296            let points = sections
297                .iter()
298                .map(|section| section[curve_index].control_points[control_index].point())
299                .collect::<Result<Vec<_>, _>>()?;
300            let interpolated = match end_tangents {
301                None => interpolate_curve(&points, degree_v, &parameters)?,
302                Some((start_direction, end_direction)) => {
303                    let chord: f64 = points
304                        .windows(2)
305                        .map(|pair| pair[1].sub(pair[0]).length())
306                        .sum();
307                    // A column whose stations coincide still needs a usable
308                    // tangent magnitude; fall back to the section spacing.
309                    let magnitude = if chord > tolerance { chord } else { 1.0 };
310                    crate::interpolate_curve_with_end_tangents(
311                        &points,
312                        &parameters,
313                        start_direction.scale(magnitude),
314                        end_direction.scale(magnitude),
315                    )?
316                }
317            };
318            knots_v = interpolated.knots.clone();
319            let weight = reference.control_points[control_index].w;
320            grid.push(
321                interpolated
322                    .control_points
323                    .iter()
324                    .map(|point| Vec4::from_point(point.point().unwrap(), weight))
325                    .collect(),
326            );
327        }
328        skins.push(NurbsSurface::new(
329            reference.degree,
330            degree_v,
331            reference.knots.clone(),
332            knots_v,
333            grid,
334        )?);
335    }
336
337    let bottom = &sections[0];
338    let top = &sections[section_count - 1];
339    let bottom_points = closed_points(bottom, tolerance)?;
340    let top_points = closed_points(top, tolerance)?;
341    let mut vertices = Vec::with_capacity(2 * curve_count);
342    for (index, point) in bottom_points.iter().chain(&top_points).enumerate() {
343        vertices.push(VertexRecord {
344            id: index as u64 + 1,
345            point: *point,
346        });
347    }
348    let mut edges = Vec::with_capacity(3 * curve_count);
349    let mut bottom_edge_ids = Vec::new();
350    let mut top_edge_ids = Vec::new();
351    let mut vertical_edge_ids = Vec::new();
352    for index in 0..curve_count {
353        let [start, end] = bottom[index].domain()?;
354        let bottom_id = 10 + index as u64;
355        let top_id = 10 + curve_count as u64 + index as u64;
356        let vertical_id = 10 + 2 * curve_count as u64 + index as u64;
357        bottom_edge_ids.push(bottom_id);
358        top_edge_ids.push(top_id);
359        vertical_edge_ids.push(vertical_id);
360        edges.push(EdgeRecord {
361            id: bottom_id,
362            curve: bottom[index].clone(),
363            t0: start,
364            t1: end,
365            start_vertex_id: index as u64 + 1,
366            end_vertex_id: ((index + 1) % curve_count) as u64 + 1,
367            degenerate: false,
368            name: None,
369        });
370        edges.push(EdgeRecord {
371            id: top_id,
372            curve: top[index].clone(),
373            t0: start,
374            t1: end,
375            start_vertex_id: (curve_count + index) as u64 + 1,
376            end_vertex_id: (curve_count + (index + 1) % curve_count) as u64 + 1,
377            degenerate: false,
378            name: None,
379        });
380        let vertical = skins[index].iso_curve_u(start)?;
381        let [v_start, v_end] = vertical.domain()?;
382        edges.push(EdgeRecord {
383            id: vertical_id,
384            curve: vertical,
385            t0: v_start,
386            t1: v_end,
387            start_vertex_id: index as u64 + 1,
388            end_vertex_id: (curve_count + index) as u64 + 1,
389            degenerate: false,
390            name: None,
391        });
392    }
393
394    let mut next_id = 1000u64;
395    let mut faces = Vec::with_capacity(curve_count + 2);
396    for index in 0..curve_count {
397        let [start, end] = bottom[index].domain()?;
398        let coedges = vec![
399            CoedgeRecord {
400                id: next_id,
401                edge_id: bottom_edge_ids[index],
402                forward: true,
403                pcurve: parameter_line(start, 0.0, end, 0.0)?,
404            },
405            CoedgeRecord {
406                id: next_id + 1,
407                edge_id: vertical_edge_ids[(index + 1) % curve_count],
408                forward: true,
409                pcurve: parameter_line(end, 0.0, end, 1.0)?,
410            },
411            CoedgeRecord {
412                id: next_id + 2,
413                edge_id: top_edge_ids[index],
414                forward: false,
415                pcurve: parameter_line(end, 1.0, start, 1.0)?,
416            },
417            CoedgeRecord {
418                id: next_id + 3,
419                edge_id: vertical_edge_ids[index],
420                forward: false,
421                pcurve: parameter_line(start, 1.0, start, 0.0)?,
422            },
423        ];
424        next_id += 4;
425        let loop_id = next_id;
426        next_id += 1;
427        let face_id = next_id;
428        next_id += 1;
429        faces.push(FaceRecord {
430            id: face_id,
431            surface: skins[index].clone(),
432            same_sense: true,
433            loops: vec![LoopRecord {
434                id: loop_id,
435                coedges,
436            }],
437            name: None,
438        });
439    }
440    if reversed_winding {
441        // The winding normalization reversed the section curve order above.
442        // Callers stamp side-face names by INPUT-curve order (the extrude
443        // and revolve wall-naming permutation, loft edition) โ€” emit side
444        // faces in input order.
445        faces.reverse();
446    }
447    faces.push(cap_face(
448        bottom,
449        &bottom_edge_ids,
450        &first_frame,
451        axis,
452        false,
453        &mut next_id,
454    )?);
455    faces.push(cap_face(
456        top,
457        &top_edge_ids,
458        &last_frame,
459        axis,
460        true,
461        &mut next_id,
462    )?);
463    let solid = BrepSolid {
464        id: next_id + 1,
465        vertices,
466        edges,
467        shells: vec![ShellRecord { id: next_id, faces }],
468        genus: 0,
469    };
470    let issues = solid.validate();
471    if issues.is_empty() {
472        Ok(solid)
473    } else {
474        Err(format!(
475            "Rust loft builder produced invalid topology: {issues:?}"
476        ))
477    }
478}