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