Skip to main content

brep_kernel/construction/loft_topology/
guided.rs

1use super::*;
2
3/// Mean of a section's sampled curve points — the section CENTROID used both
4/// to place the section on the guide and to project it onto the guide.
5fn guided_section_centroid(curves: &[NurbsCurve]) -> Result<Vec3, String> {
6    let mut sum = Vec3::default();
7    let mut count = 0usize;
8    for curve in curves {
9        let [start, end] = curve.domain()?;
10        for index in 0..16 {
11            sum = sum.add(curve.evaluate(start + (end - start) * index as f64 / 16.0)?);
12            count += 1;
13        }
14    }
15    if count == 0 {
16        return Err("guidedLoft: a section has no sampleable curves".into());
17    }
18    Ok(sum.scale(1.0 / count as f64))
19}
20
21/// Loft a set of loft-compatible cross-sections so the loft's SPINE follows a
22/// GUIDE curve (§5.8) instead of the straight centroid-to-centroid path.
23///
24/// V1 is TRANSLATION-ONLY: at each station along the guide the two bracketing
25/// sections are blended per control point (homogeneous, `w` blended too) into a
26/// compatible intermediate section, which is then RIGIDLY TRANSLATED so its
27/// centroid lands on the guide.  The sections keep their OWN orientation — they
28/// bend along the guide but are not rotated into its moving frame (that is
29/// `loft_profile_brep_guided_frame`).  The heavy lifting (skin surfaces, shared
30/// edges, planar end caps, winding normalization, validation) is delegated to
31/// `loft_profile_brep`, so the guided sections inherit all of its guarantees.
32///
33/// Clear `Err` on: fewer than 2 sections, incompatible sections, a degenerate
34/// guide, sections that do not project monotonically onto the guide, coincident
35/// stations, or a downstream loft failure.
36pub fn loft_profile_brep_guided(
37    sections: &[Vec<NurbsCurve>],
38    guide: &NurbsCurve,
39    name: Option<&str>,
40) -> Result<BrepSolid, String> {
41    loft_profile_brep_guided_core(sections, guide, name, false)
42}
43
44/// Rotation-to-frame guided loft (§5.8): like `loft_profile_brep_guided`, but
45/// intermediate sections ROTATE with the guide's rotation-minimizing moving
46/// frame (double-reflection RMF, the same frames the path sweep uses) instead
47/// of keeping their world orientation.
48///
49/// Each user section is expressed in the LOCAL frame at its own guide station,
50/// the local representations are blended, and the blend is mapped back through
51/// the frame at each output station.  Because localize→reconstruct through the
52/// SAME frame is the identity, every user section is still reproduced EXACTLY
53/// at its own station — the rotation only shapes the flow between sections.
54/// The relative rotation between two stations is independent of the arbitrary
55/// initial frame normal (a start-normal change conjugates every frame by the
56/// same constant), so the result is deterministic.
57pub fn loft_profile_brep_guided_frame(
58    sections: &[Vec<NurbsCurve>],
59    guide: &NurbsCurve,
60    name: Option<&str>,
61) -> Result<BrepSolid, String> {
62    loft_profile_brep_guided_core(sections, guide, name, true)
63}
64
65/// Rotation-minimizing frames over one ordered parameter strip of the guide.
66/// All arrays are aligned with `params` (normalized [0, 1] guide parameters).
67struct GuidedFrames {
68    params: Vec<f64>,
69    points: Vec<Vec3>,
70    tangents: Vec<Vec3>,
71    r_axes: Vec<Vec3>,
72    s_axes: Vec<Vec3>,
73}
74
75impl GuidedFrames {
76    /// Index of the frame at normalized parameter `t` (must be one of the
77    /// parameters the strip was marched over).
78    fn index_of(&self, t: f64) -> Result<usize, String> {
79        let lower = self.params.partition_point(|p| *p < t - 1e-9);
80        if lower < self.params.len() && (self.params[lower] - t).abs() <= 1e-9 {
81            Ok(lower)
82        } else {
83            Err(format!("guidedLoft: no frame marched at parameter {t}"))
84        }
85    }
86}
87
88/// March double-reflection RMF frames over the union of the output stations
89/// and the user-section stations so section localization and station
90/// reconstruction share ONE consistent strip (mirrors the path sweep's frame
91/// propagation, including the coincident-station and drift re-orthogonalize
92/// guards).
93fn guided_frames(
94    guide: &NurbsCurve,
95    g0: f64,
96    g1: f64,
97    station_params: &[f64],
98    section_params: &[f64],
99) -> Result<GuidedFrames, String> {
100    let mut params: Vec<f64> = station_params
101        .iter()
102        .chain(section_params.iter())
103        .copied()
104        .collect();
105    params.sort_by(|a, b| a.partial_cmp(b).expect("guide params are finite"));
106    params.dedup_by(|a, b| (*a - *b).abs() <= 1e-12);
107
108    let count = params.len();
109    let mut points = Vec::with_capacity(count);
110    let mut tangents = Vec::with_capacity(count);
111    for (index, t) in params.iter().enumerate() {
112        let derivatives = guide.derivatives(g0 + (g1 - g0) * t, 1)?;
113        let tangent = derivatives[1]
114            .normalized()
115            .map_err(|_| format!("guidedLoft: guide tangent is degenerate at station {index}"))?;
116        points.push(derivatives[0]);
117        tangents.push(tangent);
118    }
119
120    let mut r_axes = Vec::with_capacity(count);
121    let mut s_axes = Vec::with_capacity(count);
122    let r0 = tangents[0].perpendicular()?; // any unit vector ⟂ T0
123    s_axes.push(tangents[0].cross(r0).normalized()?);
124    r_axes.push(r0);
125    for index in 0..count - 1 {
126        let t_next = tangents[index + 1];
127        let v1 = points[index + 1].sub(points[index]);
128        let c1 = v1.dot(v1);
129        let r_candidate = if c1 <= 1e-18 {
130            r_axes[index]
131        } else {
132            let reflected_r = r_axes[index].sub(v1.scale(2.0 / c1 * v1.dot(r_axes[index])));
133            let reflected_t = tangents[index].sub(v1.scale(2.0 / c1 * v1.dot(tangents[index])));
134            let v2 = t_next.sub(reflected_t);
135            let c2 = v2.dot(v2);
136            if c2 <= 1e-18 {
137                reflected_r
138            } else {
139                reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
140            }
141        };
142        let r_next = r_candidate
143            .sub(t_next.scale(r_candidate.dot(t_next)))
144            .normalized()
145            .map_err(|_| format!("guidedLoft: frame degenerated at station {index}"))?;
146        s_axes.push(t_next.cross(r_next).normalized()?);
147        r_axes.push(r_next);
148    }
149    Ok(GuidedFrames {
150        params,
151        points,
152        tangents,
153        r_axes,
154        s_axes,
155    })
156}
157
158fn loft_profile_brep_guided_core(
159    sections: &[Vec<NurbsCurve>],
160    guide: &NurbsCurve,
161    name: Option<&str>,
162    rotate_to_frame: bool,
163) -> Result<BrepSolid, String> {
164    // Loft carries no face names; accept `name` for ABI symmetry with the other
165    // builders (the app stamps names onto the emitted face order).
166    let _ = name;
167    let tolerance = 1e-6;
168    let section_count = sections.len();
169    if section_count < 2 {
170        return Err("guidedLoft: need at least 2 sections".into());
171    }
172
173    // --- 1. Loft compatibility across the INPUT sections (mirror loft_profile_brep):
174    //        same per-section curve count, matching degrees / knot lengths+values
175    //        / weights, and each section a closed loop.  This is exactly what
176    //        loft_profile_brep will re-check on the blended sections, but failing
177    //        here gives a guided-specific message before any blending work.
178    let curve_count = sections[0].len();
179    if sections.iter().any(|section| section.len() != curve_count) {
180        return Err("guidedLoft: sections must have the same curve count".into());
181    }
182    for section in sections {
183        closed_points(section, tolerance)?;
184    }
185    for curve_index in 0..curve_count {
186        let reference = &sections[0][curve_index];
187        for (section_index, section) in sections.iter().enumerate().skip(1) {
188            let curve = &section[curve_index];
189            if curve.degree != reference.degree
190                || curve.control_points.len() != reference.control_points.len()
191            {
192                return Err(format!(
193                    "guidedLoft: section {section_index} curve {curve_index} incompatible with section 0"
194                ));
195            }
196            if curve.knots.len() != reference.knots.len()
197                || curve
198                    .knots
199                    .iter()
200                    .zip(&reference.knots)
201                    .any(|(a, b)| (a - b).abs() > 1e-9)
202            {
203                return Err(format!(
204                    "guidedLoft: section {section_index} curve {curve_index} has different knots"
205                ));
206            }
207            if curve
208                .control_points
209                .iter()
210                .zip(&reference.control_points)
211                .any(|(a, b)| (a.w - b.w).abs() > 1e-9)
212            {
213                return Err(format!(
214                    "guidedLoft: section {section_index} curve {curve_index} has different weights"
215                ));
216            }
217        }
218    }
219
220    // --- 2. Guide validity + centroid projection to arc-params uᵢ ∈ [0, 1].
221    let [g0, g1] = guide.domain()?;
222    if (g1 - g0).abs() <= tolerance {
223        return Err("guidedLoft: guide domain is degenerate".into());
224    }
225    let guide_start = guide.evaluate(g0)?;
226    let mut guide_extent = 0.0_f64;
227    for index in 1..=8 {
228        let point = guide.evaluate(g0 + (g1 - g0) * index as f64 / 8.0)?;
229        guide_extent = guide_extent.max(point.sub(guide_start).length());
230    }
231    if guide_extent <= tolerance {
232        return Err("guidedLoft: guide curve is degenerate (no spatial extent)".into());
233    }
234    let mut u_list = Vec::with_capacity(section_count);
235    for section in sections {
236        let centroid = guided_section_centroid(section)?;
237        let projection = crate::project_point_to_curve(guide, centroid)?;
238        let u = ((projection.u - g0) / (g1 - g0)).clamp(0.0, 1.0);
239        u_list.push(u);
240    }
241
242    // Sections must project STRICTLY monotonically onto the guide (either
243    // direction); a decreasing projection is normalized to increasing by
244    // reversing the section order (the loft is symmetric in section order — this
245    // just flips which cap is top / bottom).
246    let increasing = u_list.windows(2).all(|pair| pair[1] > pair[0] + tolerance);
247    let decreasing = u_list.windows(2).all(|pair| pair[1] < pair[0] - tolerance);
248    if !increasing && !decreasing {
249        return Err("guidedLoft: sections do not project monotonically onto the guide".into());
250    }
251    let mut ordered_sections: Vec<Vec<NurbsCurve>> = sections.to_vec();
252    let mut ordered_u = u_list;
253    if decreasing {
254        ordered_sections.reverse();
255        ordered_u.reverse();
256    }
257    let u_first = ordered_u[0];
258    let u_last = ordered_u[section_count - 1];
259    if u_last - u_first <= tolerance {
260        return Err("guidedLoft: sections project to coincident guide stations".into());
261    }
262
263    // --- 3. Sample the guide at M = max(24, 6·nSections) stations spanning the
264    //        sections' projected range.  At each station blend the bracketing
265    //        sections (homogeneous, per control point), then place the blend:
266    //        translation mode moves its centroid onto the guide; frame mode
267    //        blends LOCAL (per-frame) coordinates and reconstructs through the
268    //        station's RMF frame, so the sections rotate with the guide.
269    let station_count = 24usize.max(6 * section_count);
270    let station_params: Vec<f64> = (0..station_count)
271        .map(|station| {
272            let frac = station as f64 / (station_count - 1) as f64;
273            u_first + (u_last - u_first) * frac
274        })
275        .collect();
276
277    // Frame mode: one RMF strip over {stations ∪ user stations}, then each
278    // user section expressed in the frame at its own station.  The homogeneous
279    // control points store frame-local coordinates (weights untouched), so the
280    // blend loop below is identical for both modes.
281    let frames = if rotate_to_frame {
282        Some(guided_frames(guide, g0, g1, &station_params, &ordered_u)?)
283    } else {
284        None
285    };
286    let blend_sources: Vec<Vec<NurbsCurve>> = if let Some(frames) = &frames {
287        let mut localized = Vec::with_capacity(section_count);
288        for (section_index, section) in ordered_sections.iter().enumerate() {
289            let frame = frames.index_of(ordered_u[section_index])?;
290            let origin = frames.points[frame];
291            let (r, s, t_axis) = (
292                frames.r_axes[frame],
293                frames.s_axes[frame],
294                frames.tangents[frame],
295            );
296            let mut local_section = Vec::with_capacity(curve_count);
297            for curve in section {
298                let control_points = curve
299                    .control_points
300                    .iter()
301                    .map(|point| {
302                        let weight = point.w;
303                        let local = Vec3::new(point.x / weight, point.y / weight, point.z / weight)
304                            .sub(origin);
305                        Vec4 {
306                            x: local.dot(r) * weight,
307                            y: local.dot(s) * weight,
308                            z: local.dot(t_axis) * weight,
309                            w: weight,
310                        }
311                    })
312                    .collect();
313                local_section.push(NurbsCurve::new(
314                    curve.degree,
315                    curve.knots.clone(),
316                    control_points,
317                )?);
318            }
319            localized.push(local_section);
320        }
321        localized
322    } else {
323        ordered_sections.clone()
324    };
325
326    let mut blended_sections: Vec<Vec<NurbsCurve>> = Vec::with_capacity(station_count);
327    for &t in &station_params {
328        // Locate the interval [ordered_u[i], ordered_u[i+1]] containing t.
329        let mut interval = 0usize;
330        while interval + 1 < section_count - 1 && ordered_u[interval + 1] <= t {
331            interval += 1;
332        }
333        let u_lo = ordered_u[interval];
334        let u_hi = ordered_u[interval + 1];
335        let span = u_hi - u_lo;
336        if span <= tolerance {
337            return Err("guidedLoft: sections project to coincident guide stations".into());
338        }
339        let f = ((t - u_lo) / span).clamp(0.0, 1.0);
340        let section_lo = &blend_sources[interval];
341        let section_hi = &blend_sources[interval + 1];
342        // Per-curve, per-control-point homogeneous linear blend.  Because loft
343        // compatibility already forced matching weights across the input
344        // sections, the blended weight equals the shared weight for every
345        // station — so all blended sections remain mutually loft-compatible.
346        let mut blended: Vec<NurbsCurve> = Vec::with_capacity(curve_count);
347        for curve_index in 0..curve_count {
348            let curve_lo = &section_lo[curve_index];
349            let curve_hi = &section_hi[curve_index];
350            let control_points = curve_lo
351                .control_points
352                .iter()
353                .zip(&curve_hi.control_points)
354                .map(|(a, b)| Vec4 {
355                    x: a.x * (1.0 - f) + b.x * f,
356                    y: a.y * (1.0 - f) + b.y * f,
357                    z: a.z * (1.0 - f) + b.z * f,
358                    w: a.w * (1.0 - f) + b.w * f,
359                })
360                .collect();
361            blended.push(NurbsCurve::new(
362                curve_lo.degree,
363                curve_lo.knots.clone(),
364                control_points,
365            )?);
366        }
367        let placed: Vec<NurbsCurve> = if let Some(frames) = &frames {
368            // Reconstruct frame-local coordinates through the station's frame.
369            let frame = frames.index_of(t)?;
370            let origin = frames.points[frame];
371            let (r, s, t_axis) = (
372                frames.r_axes[frame],
373                frames.s_axes[frame],
374                frames.tangents[frame],
375            );
376            let mut placed = Vec::with_capacity(curve_count);
377            for curve in &blended {
378                let control_points = curve
379                    .control_points
380                    .iter()
381                    .map(|point| {
382                        let weight = point.w;
383                        let world = origin
384                            .add(r.scale(point.x / weight))
385                            .add(s.scale(point.y / weight))
386                            .add(t_axis.scale(point.z / weight));
387                        Vec4 {
388                            x: world.x * weight,
389                            y: world.y * weight,
390                            z: world.z * weight,
391                            w: weight,
392                        }
393                    })
394                    .collect();
395                placed.push(NurbsCurve::new(
396                    curve.degree,
397                    curve.knots.clone(),
398                    control_points,
399                )?);
400            }
401            placed
402        } else {
403            // Rigidly translate the blend so its centroid lands on G(t).
404            let blended_centroid = guided_section_centroid(&blended)?;
405            let guide_point = guide.evaluate(g0 + (g1 - g0) * t)?;
406            let delta = guide_point.sub(blended_centroid);
407            let mut placed = Vec::with_capacity(curve_count);
408            for curve in &blended {
409                let control_points = curve
410                    .control_points
411                    .iter()
412                    .map(|point| Vec4 {
413                        x: point.x + point.w * delta.x,
414                        y: point.y + point.w * delta.y,
415                        z: point.z + point.w * delta.z,
416                        w: point.w,
417                    })
418                    .collect();
419                placed.push(NurbsCurve::new(
420                    curve.degree,
421                    curve.knots.clone(),
422                    control_points,
423                )?);
424            }
425            placed
426        };
427        blended_sections.push(placed);
428    }
429
430    // --- 4. Loft through the guided sections (side walls + planar end caps).
431    loft_profile_brep(&blended_sections)
432        .map_err(|error| format!("guidedLoft: loft through guided sections failed: {error}"))
433}