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        cells: Default::default(),
301        id: next_id,
302        vertices,
303        edges,
304        shells: vec![ShellRecord {
305            id: next_id + 1,
306            faces,
307        }],
308        genus: 1,
309        kind: crate::BodyKind::Solid,
310    };
311    let issues = solid.validate();
312    if !issues.is_empty() {
313        return Err(KernelRefusal::internal(
314            KernelStage::Validate,
315            "validate",
316            format!(
317                "loftSolid: closed loft failed validation: {issues:?}"
318            ),
319        ));
320    }
321    // The ring's outward side depends on the sections' winding; the signed
322    // volume is the arbiter.
323    if crate::solid_signed_volume(&solid).or_refuse(KernelStage::Validate, "signed_volume")? < 0.0
324    {
325        crate::offset_shell::flip_all_faces(&mut solid)
326            ?;
327    }
328    // A ring carried round a path too tight for its own profile closes through
329    // itself, and neither the validation above nor the signed volume can see
330    // it: the enclosed space still measures. Same acceptance as the open loft.
331    crate::accept_sound(solid, "loftSolid")
332}
333
334/// The skins of a SHIFTED closed loft ([`loft_profile_brep_closed_shifted`]): each
335/// control column interpolated periodically over every lap of its curve cycle,
336/// then split at the whole laps, one piece per face.
337fn shifted_skins(
338    sections: &[Vec<NurbsCurve>],
339    parameters: &[f64],
340    curve_count: usize,
341    shift: usize,
342) -> Result<Vec<NurbsSurface>, KernelRefusal> {
343    let section_count = sections.len();
344    let gcd = |mut a: usize, mut b: usize| {
345        while b != 0 {
346            (a, b) = (b, a % b);
347        }
348        a
349    };
350    let laps = curve_count / gcd(curve_count, shift);
351    if laps * section_count > MAX_SHIFTED_COLUMN_STATIONS {
352        // Refused before any solve runs — a size the lane declines by name, not
353        // a budget that ran out, so it is a deferral rather than a non-convergence.
354        return Err(KernelRefusal::unsupported(
355            KernelStage::Collect,
356            "station_cap",
357            format!(
358                "loftSolid: a ring whose curves land {shift} along after one lap runs each skin \
359                 through {laps} laps of {section_count} stations, {} in all, past the \
360                 {MAX_SHIFTED_COLUMN_STATIONS}-station cap the loft's dense column solve can carry",
361                laps * section_count
362            ),
363        ));
364    }
365    // The knots every face shares in v: one lap's periodic interpolation, clamped.
366    let mut lap_knots = vec![parameters[0]; 4];
367    lap_knots.extend_from_slice(&parameters[1..section_count]);
368    lap_knots.extend(std::iter::repeat_n(parameters[section_count], 4));
369    let mut lap_parameters = Vec::with_capacity(laps * section_count + 1);
370    for lap in 0..laps {
371        for station in 0..section_count {
372            lap_parameters.push(lap as f64 + parameters[station]);
373        }
374    }
375    lap_parameters.push(laps as f64);
376
377    let mut grids: Vec<Vec<Vec<Vec4>>> = sections[0]
378        .iter()
379        .map(|curve| vec![Vec::new(); curve.control_points.len()])
380        .collect();
381    let mut visited = vec![false; curve_count];
382    for start in 0..curve_count {
383        if visited[start] {
384            continue;
385        }
386        let cycle: Vec<usize> = (0..laps).map(|lap| (start + lap * shift) % curve_count).collect();
387        for &curve_index in &cycle {
388            visited[curve_index] = true;
389        }
390        for control_index in 0..sections[0][start].control_points.len() {
391            let mut points = Vec::with_capacity(laps * section_count);
392            for &curve_index in &cycle {
393                for section in sections {
394                    points.push(
395                        section[curve_index].control_points[control_index]
396                            .point()
397                            .or_refuse(KernelStage::Refine, "control_point")?,
398                    );
399                }
400            }
401            let mut rest = crate::interpolate_curve_closed(&points, &lap_parameters)
402                .or_refuse(KernelStage::Refine, "interpolate_closed")?;
403            for (lap, &curve_index) in cycle.iter().enumerate() {
404                let piece = if lap + 1 < laps {
405                    let (piece, remainder) = rest
406                        .split((lap + 1) as f64)
407                        .or_refuse(KernelStage::Refine, "split")?;
408                    rest = remainder;
409                    piece
410                } else {
411                    rest.clone()
412                };
413                if piece.knots.len() != lap_knots.len()
414                    || piece
415                        .knots
416                        .iter()
417                        .zip(&lap_knots)
418                        .any(|(knot, own)| (knot - lap as f64 - own).abs() > 1e-9)
419                {
420                    return Err(KernelRefusal::internal(
421                        KernelStage::Refine,
422                        "lap_split",
423                        format!(
424                            "loftSolid: lap {lap} of a shifted ring's column did not split onto the \
425                             lap's own knots"
426                        ),
427                    ));
428                }
429                let weight = sections[0][curve_index].control_points[control_index].w;
430                grids[curve_index][control_index] = piece
431                    .control_points
432                    .iter()
433                    .map(|point| Ok(Vec4::from_point(point.point()?, weight)))
434                    .collect::<Result<Vec<_>, String>>()
435                    .or_refuse(KernelStage::Refine, "control_point")?;
436            }
437        }
438    }
439    sections[0]
440        .iter()
441        .zip(grids)
442        .map(|(reference, grid)| {
443            NurbsSurface::new(reference.degree, 3, reference.knots.clone(), lap_knots.clone(), grid)
444        })
445        .collect::<Result<Vec<_>, String>>()
446        .or_refuse(KernelStage::Fragment, "skin")
447}