Skip to main content

brep_kernel/construction/loft_topology/
closed.rs

1use super::*;
2
3/// §5.8 CLOSED loft: the sections form a RING (the last flows back into the
4/// first), producing a capless genus-1 solid. Every interpolation column runs
5/// through `interpolate_curve_closed`, so the ring is C² across the closure —
6/// not a welded seam. Topology per skin is the cylinder-wall rectangle turned
7/// on its side: u runs along the section curve (open), v through the sections
8/// (closed); the section-0 curves themselves serve as the doubled v-seam
9/// edges and the corner rings (one closed curve through every section at each
10/// section corner) are shared between adjacent skins.
11pub fn loft_profile_brep_closed(
12    input_sections: &[Vec<NurbsCurve>],
13) -> Result<BrepSolid, KernelRefusal> {
14    loft_profile_brep_closed_shifted(input_sections, 0)
15}
16
17/// The largest number of stations one interpolation column of a SHIFTED closed
18/// loft may carry, laps included — the loft's own station cap, because the
19/// column solve is dense and `O(stations³)`.
20const MAX_SHIFTED_COLUMN_STATIONS: usize = 1024;
21
22/// [`loft_profile_brep_closed`] for a ring whose sections come back SHIFTED:
23/// after the last section the cycle continues into section 0 with curve `j`
24/// flowing into curve `(j + shift) mod m`, not into curve `j`.
25///
26/// That is a twisted sweep's closure when the section is symmetric. A square
27/// rolled a quarter turn over one lap lands each side on the NEXT side's place,
28/// so the wall leaving side `j` must arrive on side `j + 1`. The faces stay ONE
29/// PER SECTION CURVE, each spanning one lap: face `j` runs from curve `j` of
30/// section 0 (its `v = 0` seam) to curve `j + shift` of section 0 (its `v = 1`
31/// seam). Its corner rings run from vertex `j` to vertex `j + shift`. The seam
32/// edges are still the section-0 curves, each used by one face at `v = 0` and one
33/// at `v = 1`, so `V − E + F = m − 2m + m = 0`.
34///
35/// C² ACROSS THE SEAM, not welded. The curve indices form cycles of
36/// `laps = m / gcd(m, shift)` under `j → j + shift`, and a control column's data
37/// runs through all of them before it repeats. So each column is interpolated
38/// PERIODICALLY over `laps × S` stations, at parameters `l + p_k` for lap `l`, and
39/// split at the whole laps into one piece per face. Each piece is the lap's own
40/// v-parameterization, `[0, 1]` on the same knots every face uses. At `shift = 0`
41/// every cycle is one lap and this is exactly [`loft_profile_brep_closed`].
42///
43/// Curves `j` and `j + shift` of section 0 must share a representation (degree,
44/// knots, weights). The seam edge at `v = 1` is the other curve, parameterized
45/// as this face's own `u`.
46pub(crate) fn loft_profile_brep_closed_shifted(
47    input_sections: &[Vec<NurbsCurve>],
48    shift: usize,
49) -> Result<BrepSolid, KernelRefusal> {
50    let tolerance = 1e-6;
51    let section_count = input_sections.len();
52    if section_count < 4 {
53        return Err(KernelRefusal::input(
54            KernelStage::Collect,
55            "section_count",
56            "loftSolid: a closed loft needs at least 4 sections",
57        ));
58    }
59    let sections = input_sections.to_vec();
60    let curve_count = validate_sections(&sections, tolerance, "loftSolid", false)?;
61    if shift >= curve_count {
62        return Err(KernelRefusal::input(
63            KernelStage::Collect,
64            "seam_shift",
65            format!(
66                "loftSolid: a closed loft's seam shift {shift} must be below its {curve_count} curves"
67            ),
68        ));
69    }
70    // The curve each one flows into across the seam.
71    let landing = |curve_index: usize| (curve_index + shift) % curve_count;
72    for curve_index in 0..curve_count {
73        let (from, to) = (&sections[0][curve_index], &sections[0][landing(curve_index)]);
74        if from.degree != to.degree
75            || from.control_points.len() != to.control_points.len()
76            || from.knots.len() != to.knots.len()
77            || from.knots.iter().zip(&to.knots).any(|(a, b)| (a - b).abs() > 1e-9)
78            || from
79                .control_points
80                .iter()
81                .zip(&to.control_points)
82                .any(|(a, b)| (a.w - b.w).abs() > 1e-12 * a.w.abs().max(1.0))
83        {
84            return Err(KernelRefusal::input(
85                KernelStage::Collect,
86                "seam_compatibility",
87                format!(
88                    "loftSolid: curve {curve_index} flows across the seam into curve {}, and the two \
89                     are not representation-compatible",
90                    landing(curve_index)
91                ),
92            ));
93        }
94    }
95
96    // Cyclic chord parameters over S stations plus the wrap span back to
97    // station 0 — the closed analogue of the open loft's averaging.
98    let mut accumulated = vec![0.0; section_count + 1];
99    let mut columns = 0usize;
100    for curve_index in 0..curve_count {
101        for control_index in 0..sections[0][curve_index].control_points.len() {
102            let mut chords = vec![0.0; section_count + 1];
103            let mut total = 0.0;
104            for station in 1..=section_count {
105                let previous = sections[station - 1][curve_index].control_points[control_index]
106                    .point()
107                    .or_refuse(KernelStage::Refine, "control_point")?;
108                let across = if station == section_count {
109                    landing(curve_index)
110                } else {
111                    curve_index
112                };
113                let current = sections[station % section_count][across].control_points
114                    [control_index]
115                    .point()
116                    .or_refuse(KernelStage::Refine, "control_point")?;
117                total += current.sub(previous).length();
118                chords[station] = total;
119            }
120            if total <= tolerance {
121                continue;
122            }
123            for station in 0..=section_count {
124                accumulated[station] += chords[station] / total;
125            }
126            columns += 1;
127        }
128    }
129    if columns == 0 {
130        return Err(KernelRefusal::input(
131            KernelStage::Classify,
132            "coincident_sections",
133            "loftSolid: sections coincide",
134        ));
135    }
136    let mut parameters = vec![0.0; section_count + 1];
137    for station in 0..=section_count {
138        parameters[station] = accumulated[station] / columns as f64;
139    }
140    parameters[0] = 0.0;
141    parameters[section_count] = 1.0;
142    if parameters.windows(2).any(|pair| pair[1] <= pair[0] + 1e-9) {
143        return Err(KernelRefusal::input(
144            KernelStage::Classify,
145            "section_order",
146            "loftSolid: closed sections are not strictly ordered",
147        ));
148    }
149
150    let mut skins = Vec::with_capacity(curve_count);
151    if shift == 0 {
152        for curve_index in 0..curve_count {
153            let reference = &sections[0][curve_index];
154            let mut grid = Vec::with_capacity(reference.control_points.len());
155            let mut knots_v = Vec::new();
156            for control_index in 0..reference.control_points.len() {
157                let points = sections
158                    .iter()
159                    .map(|section| section[curve_index].control_points[control_index].point())
160                    .collect::<Result<Vec<_>, String>>()
161                    .or_refuse(KernelStage::Refine, "control_point")?;
162                let interpolated = crate::interpolate_curve_closed(&points, &parameters)
163                    .or_refuse(KernelStage::Refine, "interpolate_closed")?;
164                knots_v = interpolated.knots.clone();
165                let weight = reference.control_points[control_index].w;
166                grid.push(
167                    interpolated
168                        .control_points
169                        .iter()
170                        .map(|point| Vec4::from_point(point.point().unwrap(), weight))
171                        .collect(),
172                );
173            }
174            skins.push(NurbsSurface::new(
175                reference.degree,
176                3,
177                reference.knots.clone(),
178                knots_v,
179                grid,
180            )
181            .or_refuse(KernelStage::Fragment, "skin")?);
182        }
183    } else {
184        skins = shifted_skins(&sections, &parameters, curve_count, shift)?;
185    }
186
187    // Topology: c corner vertices (section-0 corners), c corner-ring edges
188    // (closed v-curves at each corner), c seam edges (the section-0 curves),
189    // c skin faces. V − E + F = c − 2c + c = 0 → genus 1.
190    let base_points = closed_points(&sections[0], tolerance)?;
191    let mut vertices = Vec::with_capacity(curve_count);
192    for (index, point) in base_points.iter().enumerate() {
193        vertices.push(VertexRecord {
194            id: index as u64 + 1,
195            point: *point,
196        });
197    }
198    let mut edges = Vec::with_capacity(2 * curve_count);
199    let mut ring_edge_ids = Vec::with_capacity(curve_count);
200    let mut seam_edge_ids = Vec::with_capacity(curve_count);
201    for index in 0..curve_count {
202        let [u_start, u_end] = sections[0][index]
203            .domain()
204            .or_refuse(KernelStage::Fragment, "domain")?;
205        let _ = u_end;
206        let ring = skins[index]
207            .iso_curve_u(u_start)
208            .or_refuse(KernelStage::Fragment, "iso_curve")?;
209        let [ring_start, ring_end] = ring
210            .domain()
211            .or_refuse(KernelStage::Fragment, "domain")?;
212        let ring_id = 100 + index as u64;
213        ring_edge_ids.push(ring_id);
214        edges.push(EdgeRecord {
215            id: ring_id,
216            curve: ring,
217            t0: ring_start,
218            t1: ring_end,
219            start_vertex_id: index as u64 + 1,
220            end_vertex_id: landing(index) as u64 + 1,
221            degenerate: false,
222            name: None,
223        });
224        let seam = sections[0][index].clone();
225        let [seam_start, seam_end] = seam
226            .domain()
227            .or_refuse(KernelStage::Fragment, "domain")?;
228        let seam_id = 100 + curve_count as u64 + index as u64;
229        seam_edge_ids.push(seam_id);
230        edges.push(EdgeRecord {
231            id: seam_id,
232            curve: seam,
233            t0: seam_start,
234            t1: seam_end,
235            start_vertex_id: index as u64 + 1,
236            end_vertex_id: ((index + 1) % curve_count) as u64 + 1,
237            degenerate: false,
238            name: None,
239        });
240    }
241
242    let mut next_id = 1000u64;
243    let mut faces = Vec::with_capacity(curve_count);
244    for index in 0..curve_count {
245        let [u_start, u_end] = sections[0][index]
246            .domain()
247            .or_refuse(KernelStage::Fragment, "domain")?;
248        // Cylinder-wall rectangle: seam at v=0 forward, next corner ring up,
249        // seam at v=1 backward, own corner ring down. On a SHIFTED ring the seam
250        // at v=1 is the curve this one lands on.
251        let coedges = vec![
252            CoedgeRecord {
253                id: next_id,
254                edge_id: seam_edge_ids[index],
255                forward: true,
256                pcurve: parameter_line(u_start, 0.0, u_end, 0.0)
257                    .or_refuse(KernelStage::Fragment, "pcurve")?,
258            },
259            CoedgeRecord {
260                id: next_id + 1,
261                edge_id: ring_edge_ids[(index + 1) % curve_count],
262                forward: true,
263                pcurve: parameter_line(u_end, 0.0, u_end, 1.0)
264                    .or_refuse(KernelStage::Fragment, "pcurve")?,
265            },
266            CoedgeRecord {
267                id: next_id + 2,
268                edge_id: seam_edge_ids[landing(index)],
269                forward: false,
270                pcurve: parameter_line(u_end, 1.0, u_start, 1.0)
271                    .or_refuse(KernelStage::Fragment, "pcurve")?,
272            },
273            CoedgeRecord {
274                id: next_id + 3,
275                edge_id: ring_edge_ids[index],
276                forward: false,
277                pcurve: parameter_line(u_start, 1.0, u_start, 0.0)
278                    .or_refuse(KernelStage::Fragment, "pcurve")?,
279            },
280        ];
281        next_id += 4;
282        let loop_id = next_id;
283        next_id += 1;
284        let face_id = next_id;
285        next_id += 1;
286        faces.push(FaceRecord {
287            id: face_id,
288            surface: skins[index].clone(),
289            same_sense: true,
290            loops: vec![LoopRecord {
291                id: loop_id,
292                coedges,
293            }],
294            name: None,
295        });
296    }
297
298    let mut solid = BrepSolid {
299        mass_properties_cache: Default::default(),
300        id: next_id,
301        vertices,
302        edges,
303        shells: vec![ShellRecord {
304            id: next_id + 1,
305            faces,
306        }],
307        genus: 1,
308    };
309    let issues = solid.validate();
310    if !issues.is_empty() {
311        return Err(KernelRefusal::internal(
312            KernelStage::Validate,
313            "validate",
314            format!(
315                "loftSolid: closed loft failed validation: {issues:?}"
316            ),
317        ));
318    }
319    // The ring's outward side depends on the sections' winding; the signed
320    // volume is the arbiter.
321    if crate::solid_signed_volume(&solid).or_refuse(KernelStage::Validate, "signed_volume")? < 0.0
322    {
323        crate::offset_shell::flip_all_faces(&mut solid)
324            ?;
325    }
326    // A ring carried round a path too tight for its own profile closes through
327    // itself, and neither the validation above nor the signed volume can see
328    // it: the enclosed space still measures. Same acceptance as the open loft.
329    crate::accept_sound(solid, "loftSolid")
330}
331
332/// The skins of a SHIFTED closed loft ([`loft_profile_brep_closed_shifted`]): each
333/// control column interpolated periodically over every lap of its curve cycle,
334/// then split at the whole laps, one piece per face.
335fn shifted_skins(
336    sections: &[Vec<NurbsCurve>],
337    parameters: &[f64],
338    curve_count: usize,
339    shift: usize,
340) -> Result<Vec<NurbsSurface>, KernelRefusal> {
341    let section_count = sections.len();
342    let gcd = |mut a: usize, mut b: usize| {
343        while b != 0 {
344            (a, b) = (b, a % b);
345        }
346        a
347    };
348    let laps = curve_count / gcd(curve_count, shift);
349    if laps * section_count > MAX_SHIFTED_COLUMN_STATIONS {
350        // Refused before any solve runs — a size the lane declines by name, not
351        // a budget that ran out, so it is a deferral rather than a non-convergence.
352        return Err(KernelRefusal::unsupported(
353            KernelStage::Collect,
354            "station_cap",
355            format!(
356                "loftSolid: a ring whose curves land {shift} along after one lap runs each skin \
357                 through {laps} laps of {section_count} stations, {} in all, past the \
358                 {MAX_SHIFTED_COLUMN_STATIONS}-station cap the loft's dense column solve can carry",
359                laps * section_count
360            ),
361        ));
362    }
363    // The knots every face shares in v: one lap's periodic interpolation, clamped.
364    let mut lap_knots = vec![parameters[0]; 4];
365    lap_knots.extend_from_slice(&parameters[1..section_count]);
366    lap_knots.extend(std::iter::repeat_n(parameters[section_count], 4));
367    let mut lap_parameters = Vec::with_capacity(laps * section_count + 1);
368    for lap in 0..laps {
369        for station in 0..section_count {
370            lap_parameters.push(lap as f64 + parameters[station]);
371        }
372    }
373    lap_parameters.push(laps as f64);
374
375    let mut grids: Vec<Vec<Vec<Vec4>>> = sections[0]
376        .iter()
377        .map(|curve| vec![Vec::new(); curve.control_points.len()])
378        .collect();
379    let mut visited = vec![false; curve_count];
380    for start in 0..curve_count {
381        if visited[start] {
382            continue;
383        }
384        let cycle: Vec<usize> = (0..laps).map(|lap| (start + lap * shift) % curve_count).collect();
385        for &curve_index in &cycle {
386            visited[curve_index] = true;
387        }
388        for control_index in 0..sections[0][start].control_points.len() {
389            let mut points = Vec::with_capacity(laps * section_count);
390            for &curve_index in &cycle {
391                for section in sections {
392                    points.push(
393                        section[curve_index].control_points[control_index]
394                            .point()
395                            .or_refuse(KernelStage::Refine, "control_point")?,
396                    );
397                }
398            }
399            let mut rest = crate::interpolate_curve_closed(&points, &lap_parameters)
400                .or_refuse(KernelStage::Refine, "interpolate_closed")?;
401            for (lap, &curve_index) in cycle.iter().enumerate() {
402                let piece = if lap + 1 < laps {
403                    let (piece, remainder) = rest
404                        .split((lap + 1) as f64)
405                        .or_refuse(KernelStage::Refine, "split")?;
406                    rest = remainder;
407                    piece
408                } else {
409                    rest.clone()
410                };
411                if piece.knots.len() != lap_knots.len()
412                    || piece
413                        .knots
414                        .iter()
415                        .zip(&lap_knots)
416                        .any(|(knot, own)| (knot - lap as f64 - own).abs() > 1e-9)
417                {
418                    return Err(KernelRefusal::internal(
419                        KernelStage::Refine,
420                        "lap_split",
421                        format!(
422                            "loftSolid: lap {lap} of a shifted ring's column did not split onto the \
423                             lap's own knots"
424                        ),
425                    ));
426                }
427                let weight = sections[0][curve_index].control_points[control_index].w;
428                grids[curve_index][control_index] = piece
429                    .control_points
430                    .iter()
431                    .map(|point| Ok(Vec4::from_point(point.point()?, weight)))
432                    .collect::<Result<Vec<_>, String>>()
433                    .or_refuse(KernelStage::Refine, "control_point")?;
434            }
435        }
436    }
437    sections[0]
438        .iter()
439        .zip(grids)
440        .map(|(reference, grid)| {
441            NurbsSurface::new(reference.degree, 3, reference.knots.clone(), lap_knots.clone(), grid)
442        })
443        .collect::<Result<Vec<_>, String>>()
444        .or_refuse(KernelStage::Fragment, "skin")
445}