Skip to main content

brep_kernel/construction/sweep_topology/
rib.rs

1use super::*;
2use crate::{classify_point, PointClass, SolidClassifier};
3use serde::{Deserialize, Serialize};
4
5/// Rational ruled surface between two curves with IDENTICAL degree, knots and
6/// weight pattern (the drafted-arc rows share one `make_arc` window, so this
7/// yields the EXACT cone patch: each ruling blends radially-corresponding
8/// points at equal weights).
9pub(super) fn ruled_between(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, KernelRefusal> {
10    if bottom.degree != top.degree
11        || bottom.control_points.len() != top.control_points.len()
12        || bottom.knots.len() != top.knots.len()
13    {
14        return Err(KernelRefusal::internal(
15            KernelStage::Fragment,
16            "ruled_rows",
17            "ruled_between: rows are not representation-compatible",
18        ));
19    }
20    let grid = bottom
21        .control_points
22        .iter()
23        .zip(&top.control_points)
24        .map(|(a, b)| vec![*a, *b])
25        .collect();
26    NurbsSurface::new(
27        bottom.degree,
28        1,
29        bottom.knots.clone(),
30        vec![0.0, 0.0, 1.0, 1.0],
31        grid,
32    )
33    .or_refuse(KernelStage::Fragment, "surface_new")
34}
35
36/// Subrange of a wall ROW curve between the projections of two junction
37/// points.  Splitting (instead of rebuilding with `make_arc`) preserves the
38/// row's parameterization exactly, so the edge is the surface's own boundary
39/// restriction and its parameter-line pcurve is pointwise exact.
40pub(super) fn arc_window_subrange(row: &NurbsCurve, start: Vec3, end: Vec3) -> Result<NurbsCurve, KernelRefusal> {
41    let [d0, d1] = row.domain().or_refuse(KernelStage::Fragment, "domain")?;
42    let span = d1 - d0;
43    let u0 = project_point_to_curve(row, start).or_refuse(KernelStage::Fragment, "project_point")?.u;
44    let u1 = project_point_to_curve(row, end).or_refuse(KernelStage::Fragment, "project_point")?.u;
45    if u1 <= u0 + 1e-12 {
46        return Err(KernelRefusal::internal(
47            KernelStage::Fragment,
48            "arc_trim",
49            "draftExtrude: a drafted arc's boundary trim inverted",
50        ));
51    }
52    let epsilon = span * 1e-9;
53    let mut current = row.clone();
54    if u0 > d0 + epsilon {
55        current = current.split(u0).or_refuse(KernelStage::Fragment, "split")?.1;
56    }
57    let domain = current.domain().or_refuse(KernelStage::Fragment, "domain")?;
58    if u1 < domain[1] - epsilon && u1 > domain[0] + epsilon {
59        current = current.split(u1).or_refuse(KernelStage::Fragment, "split")?.0;
60    }
61    Ok(current)
62}
63
64/// Gradient (unnormalized surface normal direction) of a drafted WALL's
65/// implicit surface at a point on it: for a line wall the tilted plane's
66/// normal; for an arc wall the cone's ∇(ρ − r(z)) = ρ̂ + (d·turn/h)·ẑ.  Exact
67/// closed forms — the junction-conic end tangents come from their cross
68/// products.
69fn wall_gradient(
70    seg: &SegGeom,
71    point: Vec3,
72    zh: Vec3,
73    height: f64,
74    signed_d: f64,
75) -> Result<Vec3, KernelRefusal> {
76    match seg {
77        SegGeom::Line { dir, normal, .. } => dir
78            .cross(normal.scale(signed_d).add(zh.scale(height)))
79            .normalized()
80            .or_refuse(KernelStage::Fragment, "normalized"),
81        SegGeom::Arc {
82            center, turn, ..
83        } => {
84            let rel = point.sub(*center);
85            let radial = rel.sub(zh.scale(rel.dot(zh)));
86            let rho = radial.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
87            rho.add(zh.scale(signed_d * turn / height))
88                .normalized()
89                .or_refuse(KernelStage::Fragment, "normalized")
90        }
91    }
92}
93
94/// The EXACT junction edge between two adjacent drafted walls, from bottom
95/// junction `a` to top junction `b` with mid-height witness `m` (all three are
96/// exact offset-primitive intersections).  Straight when `m` is collinear
97/// (plane∧plane miter edges, tangent-junction cone rulings); otherwise the
98/// exact CONIC through `a`/`b` with the walls' analytic gradient-cross end
99/// tangents, its rational-quadratic weight solved from `m` (a conic is
100/// uniquely determined by that data, and every drafted wall∧wall intersection
101/// IS a conic — both squared implicits shrink linearly at the same rate, so
102/// their difference is a plane).
103#[allow(clippy::too_many_arguments)]
104pub(super) fn junction_edge_curve(
105    prev: &SegGeom,
106    next: &SegGeom,
107    a: Vec3,
108    m: Vec3,
109    b: Vec3,
110    zh: Vec3,
111    height: f64,
112    signed_d: f64,
113) -> Result<NurbsCurve, KernelRefusal> {
114    let chord = b.sub(a);
115    let length = chord.length();
116    if length <= 1e-12 {
117        return Err(KernelRefusal::internal(
118            KernelStage::Fragment,
119            "junction_collapsed",
120            "draftExtrude: a junction edge collapsed to a point",
121        ));
122    }
123    let along = m.sub(a).dot(chord) / (length * length);
124    let deviation = m.sub(a).sub(chord.scale(along)).length();
125    if deviation <= length * 1e-9 {
126        return make_line(a, b).or_refuse(KernelStage::Fragment, "make_line");
127    }
128    // 2D frame in the conic's plane (it contains a, b, m by construction).
129    let e1 = chord.scale(1.0 / length);
130    let plane_normal = chord
131        .cross(m.sub(a))
132        .normalized()
133        .or_refuse(KernelStage::Fragment, "normalized")?;
134    let e2 = plane_normal
135        .cross(e1)
136        .normalized()
137        .or_refuse(KernelStage::Fragment, "normalized")?;
138    let orient = |tangent: Vec3| {
139        if tangent.dot(zh) < 0.0 {
140            tangent.scale(-1.0)
141        } else {
142            tangent
143        }
144    };
145    let t0 = orient(wall_gradient(prev, a, zh, height, signed_d)?
146        .cross(wall_gradient(next, a, zh, height, signed_d)?));
147    let t2 = orient(wall_gradient(prev, b, zh, height, signed_d)?
148        .cross(wall_gradient(next, b, zh, height, signed_d)?));
149    let d0 = (t0.dot(e1), t0.dot(e2));
150    let d2 = (t2.dot(e1), t2.dot(e2));
151    let denom = d0.0 * d2.1 - d0.1 * d2.0;
152    let scale0 = d0.0.hypot(d0.1);
153    let scale2 = d2.0.hypot(d2.1);
154    if denom.abs() <= 1e-14 * scale0 * scale2 {
155        return Err(KernelRefusal::internal(
156            KernelStage::Fragment,
157            "conic_apex",
158            "draftExtrude: junction end tangents are parallel — no conic apex",
159        ));
160    }
161    // Apex: a + s·t0 = b + r·t2 solved in 2D (a = origin, b = (length, 0)).
162    let s = length * d2.1 / denom;
163    let apex = (s * d0.0, s * d0.1);
164    if apex.1.abs() <= f64::EPSILON * length {
165        return Err(KernelRefusal::internal(
166            KernelStage::Fragment,
167            "conic_apex_degenerate",
168            "draftExtrude: junction conic apex is degenerate",
169        ));
170    }
171    // Barycentric coordinates of m over (a, apex, b): m = α·a + β·apex + γ·b.
172    let mq = (m.sub(a).dot(e1), m.sub(a).dot(e2));
173    let beta = mq.1 / apex.1;
174    let gamma = (mq.0 - beta * apex.0) / length;
175    let alpha = 1.0 - beta - gamma;
176    if !(alpha > 0.0 && beta > 0.0 && gamma > 0.0) {
177        return Err(KernelRefusal::internal(
178            KernelStage::Fragment,
179            "conic_witness",
180            format!(
181            "draftExtrude: junction conic witness fell outside its control triangle \
182             (α={alpha:.3e} β={beta:.3e} γ={gamma:.3e})"
183            ),
184        ));
185    }
186    let weight = beta / (2.0 * (alpha * gamma).sqrt());
187    let apex_3d = a.add(e1.scale(apex.0)).add(e2.scale(apex.1));
188    NurbsCurve::new(
189        2,
190        vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
191        vec![
192            Vec4::from_point(a, 1.0),
193            Vec4::from_point(apex_3d, weight),
194            Vec4::from_point(b, 1.0),
195        ],
196    )
197    .or_refuse(KernelStage::Fragment, "curve_new")
198}
199
200/// In-plane circumcircle of three coplanar points (projected onto `ex`/`ey`,
201/// `np = ex×ey`).  Returns `None` when the three points are collinear.
202fn circumcircle(a: Vec3, b: Vec3, c: Vec3, ex: Vec3, ey: Vec3, np: Vec3) -> Option<(Vec3, f64)> {
203    let [ux, uy] = crate::circumcenter_2d([a.dot(ex), a.dot(ey)], [b.dot(ex), b.dot(ey)], [c.dot(ex), c.dot(ey)])?;
204    let plane_off = a.dot(np);
205    let center = ex.scale(ux).add(ey.scale(uy)).add(np.scale(plane_off));
206    Some((center, a.sub(center).length()))
207}
208
209/// Intersect an offset LINE (through `line_point`, direction `line_dir`) with an
210/// offset CIRCLE (`center`, `radius`), returning the root nearest `near` (the
211/// original junction).  A clear Err when they no longer meet (offset too large).
212fn intersect_offset_line_circle(
213    line_point: Vec3,
214    line_dir: Vec3,
215    center: Vec3,
216    radius: f64,
217    near: Vec3,
218) -> Result<Vec3, KernelRefusal> {
219    let dir = line_dir.normalized().or_refuse(KernelStage::Classify, "normalized")?;
220    let f = line_point.sub(center);
221    let b = f.dot(dir);
222    let c = f.dot(f) - radius * radius;
223    let disc = b * b - c;
224    if disc < -1e-9 {
225        return Err(KernelRefusal::input(
226            KernelStage::Classify,
227            "offset_too_large",
228            "an offset line and arc no longer meet (offset too large)",
229        ));
230    }
231    let root = disc.max(0.0).sqrt();
232    let p1 = line_point.add(dir.scale(-b + root));
233    let p2 = line_point.add(dir.scale(-b - root));
234    Ok(if p1.sub(near).length() <= p2.sub(near).length() {
235        p1
236    } else {
237        p2
238    })
239}
240
241/// Intersect two offset CIRCLES (in the plane whose normal is `plane_normal`),
242/// returning the root nearest `near`.  A clear Err when they are concentric or no
243/// longer meet.
244fn intersect_offset_circles(
245    c1: Vec3,
246    r1: f64,
247    c2: Vec3,
248    r2: f64,
249    plane_normal: Vec3,
250    near: Vec3,
251) -> Result<Vec3, KernelRefusal> {
252    let between = c2.sub(c1);
253    let d = between.length();
254    if d < 1e-9 {
255        return Err(KernelRefusal::internal(
256            KernelStage::Classify,
257            "concentric_arcs",
258            "concentric offset arcs do not meet",
259        ));
260    }
261    let axis = between.scale(1.0 / d);
262    let a = (d * d + r1 * r1 - r2 * r2) / (2.0 * d);
263    let h2 = r1 * r1 - a * a;
264    if h2 < -1e-9 {
265        return Err(KernelRefusal::input(
266            KernelStage::Classify,
267            "offset_arcs_too_large",
268            "offset arcs no longer meet (offset too large)",
269        ));
270    }
271    let h = h2.max(0.0).sqrt();
272    let base = c1.add(axis.scale(a));
273    let perp = plane_normal.cross(axis).normalized().or_refuse(KernelStage::Classify, "normalized")?;
274    let p1 = base.add(perp.scale(h));
275    let p2 = base.sub(perp.scale(h));
276    Ok(if p1.sub(near).length() <= p2.sub(near).length() {
277        p1
278    } else {
279        p2
280    })
281}
282
283/// Geometry class of one profile segment, shared by the draft-extrude builder
284/// and the in-plane offset engine: a straight LINE or a circular ARC in the
285/// plane with normal `plane_normal`.
286pub(super) enum SegGeom {
287    Line {
288        start: Vec3,
289        end: Vec3,
290        /// Unit chord direction.
291        dir: Vec3,
292        /// In-plane offset normal `plane_normal × dir` (inward on a CCW loop).
293        normal: Vec3,
294    },
295    Arc {
296        center: Vec3,
297        radius: f64,
298        /// +1 when the arc bends CCW about the plane normal (a convex arc on a
299        /// CCW loop, which SHRINKS under a positive inward offset), −1 when CW.
300        turn: f64,
301        /// ±plane_normal — the axis the arc sweeps CCW about.
302        arc_normal: Vec3,
303        start: Vec3,
304        end: Vec3,
305    },
306}
307
308impl SegGeom {
309    fn start(&self) -> Vec3 {
310        match self {
311            SegGeom::Line { start, .. } | SegGeom::Arc { start, .. } => *start,
312        }
313    }
314
315    fn end(&self) -> Vec3 {
316        match self {
317            SegGeom::Line { end, .. } | SegGeom::Arc { end, .. } => *end,
318        }
319    }
320
321    /// Naive offset image of a point ON this segment's primitive: lines
322    /// translate along their normal; arc points scale radially onto the
323    /// concentric offset circle (Err when a concave arc collapses).
324    fn offset_point(&self, point: Vec3, signed_d: f64) -> Result<Vec3, KernelRefusal> {
325        match self {
326            SegGeom::Line { normal, .. } => Ok(point.add(normal.scale(signed_d))),
327            SegGeom::Arc {
328                center,
329                radius,
330                turn,
331                ..
332            } => {
333                let r_offset = radius - signed_d * turn;
334                if r_offset <= 1e-6 {
335                    return Err(KernelRefusal::input(
336                        KernelStage::Classify,
337                        "arc_collapse",
338                        "offset: distance is too large — a concave arc collapses",
339                    ));
340                }
341                Ok(center.add(point.sub(*center).scale(r_offset / radius)))
342            }
343        }
344    }
345}
346
347/// Classify every profile segment as a LINE (all interior samples on the
348/// chord) or a circular ARC (circumcircle through start/mid/end confirmed by
349/// on-circle samples), with its turning direction about `plane_normal`.
350/// Anything else is a clear Err — the offset/draft machinery is exact for
351/// lines and circles only.
352pub(super) fn classify_profile_segments(
353    profile: &[NurbsCurve],
354    plane_normal: Vec3,
355) -> Result<Vec<SegGeom>, KernelRefusal> {
356    let tol = 1e-6;
357    if profile.is_empty() {
358        return Err(KernelRefusal::input(
359            KernelStage::Collect,
360            "profile_empty",
361            "offset: profile has no segments",
362        ));
363    }
364    let np = plane_normal.normalized().or_refuse(KernelStage::Classify, "normalized")?;
365    let ex = np.perpendicular().or_refuse(KernelStage::Classify, "perpendicular")?;
366    let ey = np.cross(ex).normalized().or_refuse(KernelStage::Classify, "normalized")?;
367    let mut segs = Vec::with_capacity(profile.len());
368    for curve in profile {
369        let [t0, t1] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
370        let start = curve.evaluate(t0).or_refuse(KernelStage::Classify, "evaluate")?;
371        let end = curve.evaluate(t1).or_refuse(KernelStage::Classify, "evaluate")?;
372        let chord = end.sub(start);
373        let chord_len = chord.length();
374        if chord_len <= tol {
375            return Err(KernelRefusal::input(
376                KernelStage::Collect,
377                "segment_length",
378                "offset: profile has a degenerate (zero-length) segment",
379            ));
380        }
381        let dir = chord.scale(1.0 / chord_len);
382        // A straight LINE if every interior sample lies on the chord.
383        let mut is_line = true;
384        for k in 1..8 {
385            let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0).or_refuse(KernelStage::Classify, "evaluate")?;
386            let rel = point.sub(start);
387            let perpendicular = rel.sub(dir.scale(rel.dot(dir))).length();
388            if perpendicular > tol * 10.0 {
389                is_line = false;
390                break;
391            }
392        }
393        if is_line {
394            segs.push(SegGeom::Line {
395                start,
396                end,
397                dir,
398                normal: np.cross(dir).normalized().or_refuse(KernelStage::Classify, "normalized")?,
399            });
400            continue;
401        }
402        // Otherwise it must be a circular ARC: fit a circle through start/mid/end.
403        let mid = curve.evaluate((t0 + t1) * 0.5).or_refuse(KernelStage::Classify, "evaluate")?;
404        let (center, radius) = circumcircle(start, mid, end, ex, ey, np).ok_or_else(|| {
405            KernelRefusal::unsupported(
406                KernelStage::Classify,
407                "segment_kind",
408                "offset: only straight lines and circular arcs are supported",
409            )
410        })?;
411        for k in 0..=8 {
412            let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0).or_refuse(KernelStage::Classify, "evaluate")?;
413            if (point.sub(center).length() - radius).abs() > tol * 10.0 {
414                return Err(KernelRefusal::unsupported(
415                    KernelStage::Classify,
416                    "segment_kind",
417                    "offset: only straight lines and circular arcs are supported",
418                ));
419            }
420        }
421        // Turning direction about the plane normal (CCW ⇒ +1 ⇒ shrink inward).
422        let bend = np.dot(mid.sub(start).cross(end.sub(mid)));
423        let (arc_normal, turn) = if bend >= 0.0 {
424            (np, 1.0)
425        } else {
426            (np.scale(-1.0), -1.0)
427        };
428        segs.push(SegGeom::Arc {
429            center,
430            radius,
431            turn,
432            arc_normal,
433            start,
434            end,
435        });
436    }
437    Ok(segs)
438}
439
440/// The junction between two consecutive segments' OFFSET primitives at signed
441/// in-plane distance `signed_d`:
442///   • offset 0 → the original shared vertex, exactly;
443///   • a TANGENT (G1) junction degenerates to the shared offset point
444///     (coincident-naive-offsets fast path);
445///   • line∧line → the exact miter V' = V + signed_d/(1+nₐ·n_b)·(nₐ+n_b);
446///   • line∧arc  → the offset line ∩ the offset circle, root nearest V;
447///   • arc∧arc   → the two offset circles ∩, root nearest V.
448/// Offsets that no longer meet (too large for the local feature) are a clear
449/// Err.
450pub(super) fn offset_junction(
451    prev: &SegGeom,
452    next: &SegGeom,
453    plane_normal: Vec3,
454    signed_d: f64,
455) -> Result<Vec3, KernelRefusal> {
456    let vertex = prev.end();
457    if signed_d == 0.0 {
458        return Ok(vertex);
459    }
460    let prev_offset = prev.offset_point(prev.end(), signed_d)?;
461    let next_offset = next.offset_point(next.start(), signed_d)?;
462    if prev_offset.sub(next_offset).length() <= 1e-6 {
463        return Ok(prev_offset.add(next_offset).scale(0.5));
464    }
465    match (prev, next) {
466        (SegGeom::Line { normal: na, .. }, SegGeom::Line { normal: nb, .. }) => {
467            let denom = 1.0 + na.dot(*nb);
468            if denom.abs() < 1e-6 {
469                return Err(KernelRefusal::input(
470                    KernelStage::Classify,
471                    "reversal_corner",
472                    "offset: degenerate (near-reversal) polyline corner",
473                ));
474            }
475            Ok(vertex.add(na.add(*nb).scale(signed_d / denom)))
476        }
477        (
478            SegGeom::Line { dir, .. },
479            SegGeom::Arc {
480                center,
481                radius,
482                turn,
483                ..
484            },
485        ) => intersect_offset_line_circle(
486            prev_offset,
487            *dir,
488            *center,
489            radius - signed_d * turn,
490            vertex,
491        ),
492        (
493            SegGeom::Arc {
494                center,
495                radius,
496                turn,
497                ..
498            },
499            SegGeom::Line { dir, .. },
500        ) => intersect_offset_line_circle(
501            next_offset,
502            *dir,
503            *center,
504            radius - signed_d * turn,
505            vertex,
506        ),
507        (
508            SegGeom::Arc {
509                center: c1,
510                radius: r1,
511                turn: turn1,
512                ..
513            },
514            SegGeom::Arc {
515                center: c2,
516                radius: r2,
517                turn: turn2,
518                ..
519            },
520        ) => intersect_offset_circles(
521            *c1,
522            r1 - signed_d * turn1,
523            *c2,
524            r2 - signed_d * turn2,
525            plane_normal,
526            vertex,
527        ),
528    }
529}
530
531/// Offset a planar profile CHAIN (a sequence of LINE and circular-ARC segments)
532/// IN-PLANE by the SIGNED distance `signed_d` along the per-segment offset normal
533/// n = (plane_normal × tangent).normalized().  Lines move to a parallel line;
534/// circular arcs move to a CONCENTRIC arc (radius r' = r − signed_d·turn — a
535/// convex-outward arc shrinks, a convex-inward arc grows).  Consecutive offset
536/// segments are re-joined at their [`offset_junction`].  `closed` treats the
537/// chain as a loop (every junction re-joined); an OPEN chain leaves its two end
538/// offsets un-joined.  Returns one reconstructed NurbsCurve per input segment
539/// (make_line / make_arc).  A self-intersecting offset (signed_d too large for
540/// a concave corner or arc) surfaces as a clear Err.
541fn offset_profile_segments(
542    profile: &[NurbsCurve],
543    plane_normal: Vec3,
544    signed_d: f64,
545    closed: bool,
546) -> Result<Vec<NurbsCurve>, KernelRefusal> {
547    let tol = 1e-6;
548    let np = plane_normal.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
549    let segs = classify_profile_segments(profile, np)?;
550    let n = segs.len();
551
552    // Naive per-segment offsets, then re-join consecutive ones exactly.
553    let mut offsets: Vec<(Vec3, Vec3)> = segs
554        .iter()
555        .map(|seg| {
556            Ok((
557                seg.offset_point(seg.start(), signed_d)?,
558                seg.offset_point(seg.end(), signed_d)?,
559            ))
560        })
561        .collect::<Result<_, KernelRefusal>>()?;
562    let junctions = if closed { n } else { n.saturating_sub(1) };
563    for i in 0..junctions {
564        let j = (i + 1) % n;
565        let point = offset_junction(&segs[i], &segs[j], np, signed_d)?;
566        offsets[i].1 = point;
567        offsets[j].0 = point;
568    }
569
570    // --- Reconstruct each offset segment as a NurbsCurve.
571    let mut out = Vec::with_capacity(n);
572    for (seg, (off_start, off_end)) in segs.iter().zip(&offsets) {
573        match seg {
574            SegGeom::Line { .. } => out.push(make_line(*off_start, *off_end).or_refuse(KernelStage::Fragment, "make_line")?),
575            SegGeom::Arc {
576                center, arc_normal, ..
577            } => {
578                let radial = off_start.sub(*center);
579                let r2 = radial.length();
580                if r2 <= tol {
581                    return Err(KernelRefusal::internal(
582                        KernelStage::Fragment,
583                        "arc_radius",
584                        "offset: reconstructed arc has a zero radius",
585                    ));
586                }
587                let ax = radial.scale(1.0 / r2);
588                let ay = arc_normal.cross(ax).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
589                let ve = off_end.sub(*center);
590                let mut angle = ve.dot(ay).atan2(ve.dot(ax));
591                if angle <= 1e-9 {
592                    angle += std::f64::consts::TAU;
593                }
594                out.push(make_arc(*center, ax, ay, r2, 0.0, angle).or_refuse(KernelStage::Fragment, "make_arc")?);
595            }
596        }
597    }
598    Ok(out)
599}
600
601/// Which way a rib grows out of its sketch — SolidWorks' **Extrusion Direction**
602/// control, and the same two choices it offers.
603///
604/// The two are not variants of one construction: they SWAP which axis carries the
605/// thickness and which carries the growth, which is why a rib built in the wrong
606/// one lies down where it should stand up.
607#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
608#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
609pub enum RibExtrusion {
610    /// **Parallel to Sketch** (SolidWorks' default, and what everyone means by a
611    /// rib or a gusset): the material grows PARALLEL to the sketch plane and the
612    /// thickness is applied NORMAL to it. A line drawn between two walls becomes a
613    /// thin fin standing ON the sketch plane, growing across it until it lands on
614    /// the part.
615    #[default]
616    ParallelToSketch,
617    /// **Normal to Sketch**: the material grows NORMAL to the sketch plane and the
618    /// thickness is applied IN it. The chain is thickened inside its own plane and
619    /// that ribbon is driven off the plane — walls hanging under a sketch.
620    NormalToSketch,
621}
622
623/// Rib / stiffener (§6.6) — SolidWorks' Rib, both extrusion directions, with its
624/// **Up To Next** end condition.
625///
626/// The chain is thickened by `thickness` and grown along `extrude_dir` until it
627/// LANDS ON THE PART. Which axis carries which is [`RibExtrusion`]'s whole
628/// purpose: `ParallelToSketch` offsets ±thickness/2 along the plane NORMAL and
629/// sweeps the chain IN the plane; `NormalToSketch` offsets ±thickness/2 INSIDE
630/// the plane (miter-joined, straight caps across the open ends) and sweeps that
631/// ribbon along the plane normal.
632///
633/// # Up To Next
634///
635/// There is no depth. SolidWorks' rib has exactly one end condition — the rib
636/// develops until it meets the next faces and the feature FAILS if any part of it
637/// meets nothing — and this reproduces it exactly rather than approximating it:
638/// the chain is swept a generous `reach` (twice the part's bounding diagonal, so
639/// it certainly crosses the part), the sweep is CUT BY THE PART
640/// (`slab − solid`), and the piece that grew out of the sketch is kept. Its
641/// termination surface is therefore the part's own faces, whatever shape they are.
642/// A piece still running at `reach` never landed, and that is the documented
643/// failure — not a silently truncated rib.
644///
645/// `plane_normal` is the profile's own plane when the caller knows it (a sketch
646/// publishes the plane it was drawn on). `None` falls back to deriving the plane
647/// from the chain's bends, which no single straight segment can supply.
648///
649/// # Face names
650///
651/// Every face of the result is named, and every name is a pure function of
652/// `names` and the geometry — never of the order the booleans met the faces in.
653/// See [`RibNames`] for the table.
654///
655/// V1 SCOPE: POLYLINE profiles only — arcs/curves return a clear Err.
656pub fn rib_from_profile(
657    solid: &BrepSolid,
658    profile: &[NurbsCurve],
659    thickness: f64,
660    extrude_dir: Vec3,
661    plane_normal: Option<Vec3>,
662    extrusion: RibExtrusion,
663    names: &RibNames,
664) -> Result<BrepSolid, KernelRefusal> {
665    let tolerance = 1e-6;
666    if profile.is_empty() {
667        return Err(KernelRefusal::input(
668            KernelStage::Collect,
669            "profile_count",
670            "rib: profile needs at least 1 curve forming an open chain",
671        ));
672    }
673    if names.segments.len() != profile.len() {
674        return Err(KernelRefusal::input(
675            KernelStage::Collect,
676            "segment_names",
677            format!(
678                "rib: {} segment names for {} profile curves",
679                names.segments.len(),
680                profile.len()
681            ),
682        ));
683    }
684    if !(thickness > 0.0) {
685        return Err(KernelRefusal::input(
686            KernelStage::Collect,
687            "thickness",
688            "rib: thickness must be positive",
689        ));
690    }
691
692    // --- 1. Extract the ordered chain vertices (segment endpoints) and verify the
693    //        chain is connected end→start.  Segments may be LINES or circular ARCS.
694    let mut vertices = Vec::with_capacity(profile.len() + 1);
695    let mut samples = Vec::new();
696    for (index, curve) in profile.iter().enumerate() {
697        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
698        let v_start = curve.evaluate(start).or_refuse(KernelStage::Collect, "evaluate")?;
699        let v_end = curve.evaluate(end).or_refuse(KernelStage::Collect, "evaluate")?;
700        if v_end.sub(v_start).length() <= tolerance {
701            return Err(KernelRefusal::input(
702                KernelStage::Collect,
703                "segment_length",
704                "rib: profile has a degenerate (zero-length) segment",
705            ));
706        }
707        if index == 0 {
708            vertices.push(v_start);
709        } else if v_start.sub(*vertices.last().unwrap()).length() > tolerance {
710            return Err(KernelRefusal::input(
711                KernelStage::Collect,
712                "chain_connected",
713                format!("rib: profile chain is not connected at curve {index}"),
714            ));
715        }
716        vertices.push(v_end);
717        // Skip k = 0 after the first segment: it duplicates the previous
718        // segment's endpoint, which would otherwise inject a zero-length step
719        // and cancel the corner bend used to derive the plane normal.
720        let first_k = if index == 0 { 0 } else { 1 };
721        for k in first_k..=8 {
722            samples.push(curve.evaluate(start + (end - start) * k as f64 / 8.0).or_refuse(KernelStage::Collect, "evaluate")?);
723        }
724    }
725    let count = vertices.len();
726    if count < 2 {
727        return Err(KernelRefusal::input(
728            KernelStage::Collect,
729            "vertex_count",
730            "rib: profile needs at least 2 distinct vertices",
731        ));
732    }
733
734    // --- 2. A rib thickens an OPEN profile; a closed loop is an ordinary
735    //        extrude, not a rib.
736    if vertices[count - 1].sub(vertices[0]).length() <= tolerance {
737        return Err(KernelRefusal::input(
738            KernelStage::Collect,
739            "chain_closed",
740            "rib: profile chain is closed; rib expects an open chain",
741        ));
742    }
743
744    // --- 3. The profile plane normal np.  A caller-supplied plane wins: it is the
745    //        plane the profile was AUTHORED on (a sketch publishes it), so it is
746    //        both unambiguous in sign and defined for a chain with no bend at all.
747    //        Otherwise derive it from the sampled chain's bends (robust for arc
748    //        segments), which a fully collinear chain cannot yield.  Either way
749    //        the chain must lie in the plane.
750    let np = match plane_normal {
751        Some(supplied) => supplied.normalized().map_err(|_| {
752            KernelRefusal::input(
753                KernelStage::Collect,
754                "plane_normal",
755                "rib: the supplied profile plane normal is degenerate",
756            )
757        })?,
758        None => {
759            let mut normal = Vec3::default();
760            for i in 1..samples.len() - 1 {
761                let a = samples[i].sub(samples[i - 1]);
762                let b = samples[i + 1].sub(samples[i]);
763                normal = normal.add(a.cross(b));
764            }
765            // A collinear chain lies in every plane through its line: the
766            // input does not pick one, so the rib refuses rather than choosing.
767            normal.normalized().map_err(|_| {
768                KernelRefusal::ill_posed(
769                    KernelStage::Classify,
770                    "plane_undetermined",
771                    "rib: profile is collinear and no profile plane was supplied; cannot determine \
772                 its plane",
773                )
774            })?
775        }
776    };
777    let origin = vertices[0];
778    if samples
779        .iter()
780        .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
781    {
782        return Err(KernelRefusal::input(
783            KernelStage::Collect,
784            "profile_planar",
785            if plane_normal.is_some() {
786                "rib: profile does not lie in the supplied plane"
787            } else {
788                "rib: profile is not planar"
789            },
790        ));
791    }
792
793    // --- 4. How far to sweep before the part cuts the rib back: twice the part's
794    //        bounding diagonal certainly crosses it from anywhere on the chain, so
795    //        the CUT decides the rib's extent, never this number.
796    let reach = sweep_reach(solid, &vertices)?;
797
798    // --- 5. Build the over-long slab for the requested extrusion direction.  The
799    //        two arms differ only in which axis carries the thickness.
800    let mut slab = match extrusion {
801        RibExtrusion::ParallelToSketch => {
802            // The growth direction lies IN the plane; anything out of plane is a
803            // caller error, not something to silently project away.
804            let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
805            let along = along.normalized().map_err(|_| {
806                KernelRefusal::input(
807                    KernelStage::Collect,
808                    "direction_in_plane",
809                    "rib: a Parallel-to-Sketch rib grows INSIDE its sketch plane, but the requested \
810                 direction is perpendicular to it",
811                )
812            })?;
813            parallel_slab(profile, &vertices, np, along, thickness, reach)?
814        }
815        RibExtrusion::NormalToSketch => {
816            let along = extrude_dir.normalized().map_err(|_| {
817                KernelRefusal::input(
818                    KernelStage::Collect,
819                    "direction",
820                    "rib: extrude direction is degenerate",
821                )
822            })?;
823            normal_slab(profile, np, along, thickness, reach)?
824        }
825    };
826
827    // --- 6. Up To Next: cut the over-long slab by the part and keep the piece the
828    //        sketch grew.  That piece is the rib; its far end IS the part's faces.
829    let along = match extrusion {
830        RibExtrusion::ParallelToSketch => {
831            let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
832            along.normalized().or_refuse(KernelStage::Collect, "normalized")?
833        }
834        RibExtrusion::NormalToSketch => extrude_dir.normalized().or_refuse(KernelStage::Collect, "normalized")?,
835    };
836
837    // Both operands go through the cut and the fuse carrying SOURCE TOKENS, not
838    // names: the booleans number split fragments in the order they meet them,
839    // and a part may already hold a `Floor` and a `Floor_1`, so a propagated name
840    // cannot say which face a fragment came from. The tokens can, and
841    // `restamp_rib_faces` turns them into names once the result exists.
842    let roles = slab_face_names(names, extrusion, profile.len());
843    let slab_faces: usize = slab.shells.iter().map(|shell| shell.faces.len()).sum();
844    if slab_faces != roles.len() {
845        return Err(KernelRefusal::internal(
846            KernelStage::Fragment,
847            "slab_faces",
848            format!(
849                "rib: the slab builder produced {slab_faces} faces, expected {}",
850                roles.len()
851            ),
852        ));
853    }
854    let (part, part_names) = tokenised(solid, TARGET_TOKEN);
855    for (index, face) in slab.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()).enumerate() {
856        face.name = Some(format!("{SOURCE_TOKEN}{RIB_TOKEN}{index}"));
857    }
858    let sources = FaceSources {
859        part: part_names,
860        rib: roles,
861    };
862
863    let seeds = chain_probe_seeds(profile)?;
864    let rib = up_to_next(&part, &slab, &seeds, along, reach)
865        .map_err(|error| error.with_message(|error| sources.detokenise(error)))?;
866    let Some(rib) = rib else {
867        // Every bit of the sweep was already material: the rib adds nothing, and
868        // the part is its own answer.  Not an error — the same document with a
869        // thicker wall would build exactly this.
870        return Ok(solid.clone());
871    };
872
873    let mut fused = boolean_operation(
874        &part,
875        &rib,
876        BooleanOperation::Union,
877        &BooleanOptions::default(),
878    )
879    .map_err(|error| {
880        error.with_message(|error| {
881            format!(
882                "rib: union of the rib into the part failed: {}",
883                sources.detokenise(error)
884            )
885        })
886    })?;
887    let chord = vertices[count - 1].sub(vertices[0]).normalized().or_refuse(KernelStage::Collect, "normalized")?;
888    let frame = RibFrame {
889        origin: vertices[0],
890        thickness: match extrusion {
891            RibExtrusion::ParallelToSketch => np,
892            RibExtrusion::NormalToSketch => np.cross(chord).normalized().or_refuse(KernelStage::Collect, "normalized")?,
893        },
894        chord,
895        growth: along,
896        quantum: 1e-7 * reach.max(1.0),
897    };
898    restamp_rib_faces(&mut fused, &sources, &names.feature, &frame);
899    crate::feature_pipeline::features::common::stamp_derived_edge_names(&mut fused);
900    Ok(fused)
901}
902
903/// Twice the part's bounding diagonal, measured from the chain too so a sketch
904/// standing off the part still sweeps across it.  The rib's real extent is decided
905/// by the cut in [`up_to_next`]; this only has to be generous.
906fn sweep_reach(solid: &BrepSolid, chain: &[Vec3]) -> Result<f64, KernelRefusal> {
907    let mut min = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
908    let mut max = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
909    let mut extend = |point: Vec3| {
910        min = Vec3::new(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z));
911        max = Vec3::new(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z));
912    };
913    for vertex in &solid.vertices {
914        extend(vertex.point);
915    }
916    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
917        for row in &face.surface.control_points {
918            for point in row {
919                extend(point.point().or_refuse(KernelStage::Collect, "control_point")?);
920            }
921        }
922    }
923    for point in chain {
924        extend(*point);
925    }
926    let diagonal = max.sub(min).length();
927    if !(diagonal > 0.0) || !diagonal.is_finite() {
928        return Err(KernelRefusal::input(
929            KernelStage::Collect,
930            "target_extent",
931            "rib: the target solid has no extent to grow the rib against",
932        ));
933    }
934    Ok(diagonal * 2.0)
935}
936
937/// Points ON the chain, one per segment — where the rib starts, and so where the
938/// search for its free-space piece begins.
939///
940/// It has to be the chain ITSELF, not the average of its vertices: for a bent
941/// chain that average is off the chain entirely (an L's vertex centroid lands
942/// exactly on the thickened ribbon's inner corner, a knife-edge the classifier
943/// can only answer "on"), and a probe that starts on a boundary finds no piece.
944fn chain_probe_seeds(profile: &[NurbsCurve]) -> Result<Vec<Vec3>, KernelRefusal> {
945    let mut seeds = Vec::with_capacity(profile.len());
946    for curve in profile {
947        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
948        seeds.push(curve.evaluate(start + (end - start) * 0.5).or_refuse(KernelStage::Collect, "evaluate")?);
949    }
950    if seeds.is_empty() {
951        return Err(KernelRefusal::input(
952            KernelStage::Collect,
953            "profile_empty",
954            "rib: profile has no points to grow from",
955        ));
956    }
957    Ok(seeds)
958}
959
960/// **Parallel to Sketch**: the chain swept `reach` along the IN-PLANE direction
961/// `along` gives a closed region inside the sketch plane; that region, offset to
962/// −thickness/2 and extruded `thickness` along the plane normal, is the fin.
963///
964/// The loop must be simple, so a chain that doubles back across its own sweep
965/// self-intersects here and the extrude refuses — the documented V1 limit.
966fn parallel_slab(
967    profile: &[NurbsCurve],
968    vertices: &[Vec3],
969    np: Vec3,
970    along: Vec3,
971    thickness: f64,
972    reach: f64,
973) -> Result<BrepSolid, KernelRefusal> {
974    let offset = along.scale(reach);
975    let chain_start = vertices[0];
976    let chain_end = *vertices.last().expect("chain has vertices");
977    let mut region: Vec<NurbsCurve> = Vec::with_capacity(profile.len() * 2 + 2);
978    for curve in profile {
979        region.push(curve.clone());
980    }
981    region.push(make_line(chain_end, chain_end.add(offset)).or_refuse(KernelStage::Fragment, "make_line")?);
982    for curve in profile.iter().rev() {
983        region.push(super::extrude::translated_curve(&curve.reversed().or_refuse(KernelStage::Fragment, "reversed")?, offset)?);
984    }
985    region.push(make_line(chain_start.add(offset), chain_start).or_refuse(KernelStage::Fragment, "make_line")?);
986
987    // Centre the thickness on the sketch plane: start half a thickness under it
988    // and extrude a full thickness back through.
989    let base = region
990        .iter()
991        .map(|curve| super::extrude::translated_curve(curve, np.scale(-thickness * 0.5)))
992        .collect::<Result<Vec<_>, KernelRefusal>>()?;
993    extrude_profile_brep(&base, np, thickness).map_err(|error| {
994        error.with_message(|error| {
995            format!("rib: sweeping the profile inside its plane failed: {error}")
996        })
997    })
998}
999
1000/// **Normal to Sketch**: the chain thickened INSIDE its own plane (miter-offset
1001/// ±thickness/2, straight caps across the two open ends → a closed thin loop),
1002/// extruded `reach` along `along` (the plane normal).
1003fn normal_slab(
1004    profile: &[NurbsCurve],
1005    np: Vec3,
1006    along: Vec3,
1007    thickness: f64,
1008    reach: f64,
1009) -> Result<BrepSolid, KernelRefusal> {
1010    let half = thickness * 0.5;
1011    let left = offset_profile_segments(profile, np, half, false)
1012        .map_err(|error| error.with_message(|error| format!("rib: {error}")))?;
1013    let right = offset_profile_segments(profile, np, -half, false)
1014        .map_err(|error| error.with_message(|error| format!("rib: {error}")))?;
1015    let left_first = &left[0];
1016    let left_last = &left[left.len() - 1];
1017    let right_first = &right[0];
1018    let right_last = &right[right.len() - 1];
1019    let left_start = left_first.evaluate(left_first.domain().or_refuse(KernelStage::Fragment, "domain")?[0]).or_refuse(KernelStage::Fragment, "evaluate")?;
1020    let left_end = left_last.evaluate(left_last.domain().or_refuse(KernelStage::Fragment, "domain")?[1]).or_refuse(KernelStage::Fragment, "evaluate")?;
1021    let right_start = right_first.evaluate(right_first.domain().or_refuse(KernelStage::Fragment, "domain")?[0]).or_refuse(KernelStage::Fragment, "evaluate")?;
1022    let right_end = right_last.evaluate(right_last.domain().or_refuse(KernelStage::Fragment, "domain")?[1]).or_refuse(KernelStage::Fragment, "evaluate")?;
1023    let mut thin_loop: Vec<NurbsCurve> = Vec::with_capacity(left.len() + right.len() + 2);
1024    for curve in &left {
1025        thin_loop.push(curve.clone());
1026    }
1027    thin_loop.push(make_line(left_end, right_end).or_refuse(KernelStage::Fragment, "make_line")?);
1028    for curve in right.iter().rev() {
1029        thin_loop.push(curve.reversed().or_refuse(KernelStage::Fragment, "reversed")?);
1030    }
1031    thin_loop.push(make_line(right_start, left_start).or_refuse(KernelStage::Fragment, "make_line")?);
1032    extrude_profile_brep(&thin_loop, along, reach).map_err(|error| {
1033        error.with_message(|error| {
1034            format!("rib: extrude of the thickened profile failed: {error}")
1035        })
1036    })
1037}
1038
1039/// SolidWorks' **Up To Next**, exactly: cut the over-long `slab` by the part and
1040/// keep the piece the sketch grew into.
1041///
1042/// `slab − solid` leaves the sweep's free-space pieces, and the boolean assembler
1043/// already groups a disconnected result into ONE SHELL PER PIECE (it unions faces
1044/// by shared edges), so the pieces are the result's shells. The piece containing
1045/// the sketch is the rib; anything past the part is a different piece and is
1046/// dropped — that, not a bounding box, is what makes the rib stop at the part's
1047/// own faces whatever shape they are.
1048///
1049/// `Ok(None)` means the sweep was entirely inside existing material: there is
1050/// nothing to add. A piece still running at `reach` never landed on anything, and
1051/// that is SolidWorks' documented failure ("if any portion of the solid feature
1052/// generated does not hit a Next face it fails").
1053fn up_to_next(
1054    solid: &BrepSolid,
1055    slab: &BrepSolid,
1056    seeds: &[Vec3],
1057    along: Vec3,
1058    reach: f64,
1059) -> Result<Option<BrepSolid>, KernelRefusal> {
1060    let free = match boolean_operation(
1061        slab,
1062        solid,
1063        BooleanOperation::Subtract,
1064        &BooleanOptions::default(),
1065    ) {
1066        Ok(free) => free,
1067        // A subtract that refuses because the operands are disjoint means the
1068        // sweep never reached the part at all — the Up To Next failure, reported
1069        // as itself rather than as a boolean's internal complaint.
1070        Err(error) => {
1071            // ESSENTIAL REFUSAL (see the sibling below): the cut refusing because
1072            // the operands are disjoint IS "the rib met nothing". The cut's own
1073            // class rides through under the rib's text.
1074            return Err(error.with_message(|error| {
1075                format!(
1076                "rib: RIB_UP_TO_NEXT_UNBOUNDED — the rib never reaches the part, so it has \
1077                 nothing to stop against (SolidWorks' Up To Next requires every part of a rib \
1078                 to meet a face); check the rib's direction — the cut reported: {error}"
1079                )
1080            }))
1081        }
1082    };
1083    if free.shells.is_empty() {
1084        return Ok(None);
1085    }
1086
1087    // Where the rib actually begins: the first point along the sweep from each
1088    // seed that is NOT already material. The chain can be drawn inside a wall, and
1089    // the rib is then the free space just beyond it.
1090    let classifier = SolidClassifier::new(solid, 1e-6).or_refuse(KernelStage::Select, "classifier")?;
1091    let steps = 64;
1092    let mut probes = Vec::new();
1093    for seed in seeds {
1094        for step in 1..=steps {
1095            let point = seed.add(along.scale(reach * step as f64 / steps as f64 * 0.5));
1096            if classifier.classify(point).or_refuse(KernelStage::Select, "classify")?.class == PointClass::Out {
1097                probes.push(point);
1098                break;
1099            }
1100        }
1101    }
1102    if probes.is_empty() {
1103        // Every sample along the sweep sits inside the part: nothing to add.
1104        return Ok(None);
1105    }
1106
1107    let mut kept: Option<BrepSolid> = None;
1108    for shell in &free.shells {
1109        let piece = solid_from_shell(&free, shell);
1110        let grown_here = probes
1111            .iter()
1112            .map(|probe| classify_point(*probe, &piece, 1e-6))
1113            .collect::<Result<Vec<_>, String>>()
1114            .or_refuse(KernelStage::Select, "classify_point")?
1115            .into_iter()
1116            .any(|classification| classification.class == PointClass::In);
1117        if !grown_here {
1118            continue;
1119        }
1120        // SolidWorks' cardinal rule for Up To Next: a piece that is still going at
1121        // the end of the sweep never met a face.
1122        let overrun = piece
1123            .vertices
1124            .iter()
1125            .map(|vertex| vertex.point.sub(seeds[0]).dot(along))
1126            .fold(f64::NEG_INFINITY, f64::max);
1127        if overrun >= reach * 0.99 {
1128            // ESSENTIAL REFUSAL — do not "open" this one. Unlike a gate that
1129            // refuses data the machinery could already answer, this is the
1130            // feature's DEFINITION: SolidWorks' rib has one end condition, and
1131            // "if any portion of the solid feature generated does not hit a Next
1132            // face it fails" is the rule, not a limitation of ours. Accepting it
1133            // would ship a rib hanging in space with a far end at an arbitrary
1134            // sweep distance, which is exactly the bug this feature was reported
1135            // for. The right repair is always the rib's DIRECTION, never this
1136            // check.
1137            return Err(KernelRefusal::input(
1138                KernelStage::Select,
1139                "up_to_next_unbounded",
1140                "rib: RIB_UP_TO_NEXT_UNBOUNDED — part of the rib never lands on the part, so \
1141                 it has no face to stop against (SolidWorks' Up To Next requires the whole rib \
1142                 to terminate on a face). Turn the rib around with `direction`, or move the \
1143                 profile so its sweep meets the part",
1144            ));
1145        }
1146        kept = Some(match kept {
1147            None => piece,
1148            Some(previous) => boolean_operation(
1149                &previous,
1150                &piece,
1151                BooleanOperation::Union,
1152                &BooleanOptions::default(),
1153            )
1154            .map_err(|error| {
1155                error.with_message(|error| {
1156                    format!("rib: joining the rib's own pieces failed: {error}")
1157                })
1158            })?,
1159        });
1160    }
1161    Ok(kept)
1162}
1163
1164/// One shell of `source` as a solid in its own right, carrying only the edges and
1165/// vertices its faces use — how a disconnected boolean result is taken apart into
1166/// the pieces the assembler already separated.
1167fn solid_from_shell(source: &BrepSolid, shell: &ShellRecord) -> BrepSolid {
1168    let edge_ids: std::collections::HashSet<u64> = shell
1169        .faces
1170        .iter()
1171        .flat_map(|face| &face.loops)
1172        .flat_map(|loop_record| &loop_record.coedges)
1173        .map(|coedge| coedge.edge_id)
1174        .collect();
1175    let edges: Vec<EdgeRecord> = source
1176        .edges
1177        .iter()
1178        .filter(|edge| edge_ids.contains(&edge.id))
1179        .cloned()
1180        .collect();
1181    let vertex_ids: std::collections::HashSet<u64> = edges
1182        .iter()
1183        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1184        .collect();
1185    BrepSolid {
1186        mass_properties_cache: Default::default(),
1187        cells: Default::default(),
1188        id: source.id,
1189        vertices: source
1190            .vertices
1191            .iter()
1192            .filter(|vertex| vertex_ids.contains(&vertex.id))
1193            .cloned()
1194            .collect(),
1195        edges,
1196        shells: vec![shell.clone()],
1197        genus: 0,
1198        kind: crate::BodyKind::Solid,
1199    }
1200}
1201
1202// ===========================================================================
1203// Face names
1204// ===========================================================================
1205
1206/// What a rib calls its faces: the feature id every name starts with, and the
1207/// source name of each profile curve (`{sketchId}:G{gid}` for a sketch segment),
1208/// index-aligned with the profile.
1209///
1210/// | face | Parallel to Sketch | Normal to Sketch |
1211/// |---|---|---|
1212/// | the rib's two walls | `{id}:A` (−thickness axis), `{id}:B` (+) | `{id}:{segment}_A`, `{id}:{segment}_B` per segment |
1213/// | the rib's top (on the sketch) | `{id}:{segment}_TOP` per segment | `{id}:TOP` |
1214/// | its ends, at the chain's start and end | `{id}:START`, `{id}:END` | `{id}:START`, `{id}:END` |
1215/// | the far end (cut away by Up To Next) | `{id}:{segment}_FAR` | `{id}:FAR` |
1216///
1217/// The thickness axis is the sketch plane's authored normal for a Parallel rib,
1218/// and for a Normal rib the in-plane normal `plane normal × segment direction` —
1219/// the left of the chain seen from the side the normal points to.
1220///
1221/// A face the rib SPLITS is named by `restamp_rib_faces`: one fragment keeps
1222/// the name and the others are named after the rib and the side they are on.
1223#[derive(Clone, Debug, PartialEq, Eq)]
1224pub struct RibNames {
1225    pub feature: String,
1226    pub segments: Vec<String>,
1227}
1228
1229impl RibNames {
1230    /// Names for a caller with no segment identity to offer: `SEG{i}`.
1231    pub fn positional(feature: &str, count: usize) -> Self {
1232        Self {
1233            feature: feature.to_string(),
1234            segments: (0..count).map(|index| format!("SEG{index}")).collect(),
1235        }
1236    }
1237}
1238
1239/// The slab's face names in `extrude_profile_brep`'s face order: the region's
1240/// sides in input order, then the base cap, then the far cap.
1241///
1242/// Parallel region: `[segments…, end line, far segments reversed…, start line]`,
1243/// base cap at −thickness/2 along the normal. Normal region: `[+offset
1244/// segments…, end cap, −offset segments reversed…, start cap]`, base cap on the
1245/// sketch plane.
1246fn slab_face_names(names: &RibNames, extrusion: RibExtrusion, count: usize) -> Vec<String> {
1247    let id = &names.feature;
1248    let segment = |index: usize, role: &str| format!("{id}:{}_{role}", names.segments[index]);
1249    let mut faces = Vec::with_capacity(2 * count + 4);
1250    let (near, far, base, top) = match extrusion {
1251        RibExtrusion::ParallelToSketch => ("TOP", "FAR", format!("{id}:A"), format!("{id}:B")),
1252        RibExtrusion::NormalToSketch => ("B", "A", format!("{id}:TOP"), format!("{id}:FAR")),
1253    };
1254    faces.extend((0..count).map(|index| segment(index, near)));
1255    faces.push(format!("{id}:END"));
1256    faces.extend((0..count).rev().map(|index| segment(index, far)));
1257    faces.push(format!("{id}:START"));
1258    faces.push(base);
1259    faces.push(top);
1260    faces
1261}
1262
1263/// Leads every source token, so no authored or derived name can spell one.
1264const SOURCE_TOKEN: char = '\u{1}';
1265const TARGET_TOKEN: char = 'T';
1266const RIB_TOKEN: char = 'R';
1267
1268/// A copy of `solid` whose faces carry `{SOURCE_TOKEN}{kind}{index}` instead of
1269/// their names, plus the names, index-aligned.
1270fn tokenised(solid: &BrepSolid, kind: char) -> (BrepSolid, Vec<Option<String>>) {
1271    let mut copy = solid.clone();
1272    let mut names = Vec::new();
1273    for face in copy.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()) {
1274        names.push(face.name.take());
1275        face.name = Some(format!("{SOURCE_TOKEN}{kind}{}", names.len() - 1));
1276    }
1277    (copy, names)
1278}
1279
1280/// Where each token points: the part's own face names and the slab's roles.
1281struct FaceSources {
1282    part: Vec<Option<String>>,
1283    rib: Vec<String>,
1284}
1285
1286impl FaceSources {
1287    /// `(kind, index)` of a token-named face. The booleans append `_n` to the
1288    /// later fragments of a split face; the digits after the kind end the index.
1289    fn parse(name: &str) -> Option<(char, usize)> {
1290        let mut chars = name.strip_prefix(SOURCE_TOKEN)?.chars();
1291        let kind = chars.next()?;
1292        let digits: String = chars.take_while(|c| c.is_ascii_digit()).collect();
1293        Some((kind, digits.parse().ok()?))
1294    }
1295
1296    fn name(&self, kind: char, index: usize) -> Option<&str> {
1297        match kind {
1298            TARGET_TOKEN => self.part.get(index)?.as_deref(),
1299            RIB_TOKEN => self.rib.get(index).map(String::as_str),
1300            _ => None,
1301        }
1302    }
1303
1304    /// A boolean's refusal text with every token put back as the name it stands for.
1305    fn detokenise(&self, text: &str) -> String {
1306        let mut out = String::with_capacity(text.len());
1307        let mut chars = text.chars().peekable();
1308        while let Some(c) = chars.next() {
1309            if c != SOURCE_TOKEN {
1310                out.push(c);
1311                continue;
1312            }
1313            let kind = chars.next();
1314            let mut digits = String::new();
1315            while let Some(digit) = chars.peek().copied().filter(char::is_ascii_digit) {
1316                digits.push(digit);
1317                chars.next();
1318            }
1319            match (kind, digits.parse::<usize>()) {
1320                (Some(kind), Ok(index)) => out.push_str(self.name(kind, index).unwrap_or("<unnamed>")),
1321                _ => {
1322                    out.extend(kind);
1323                    out.push_str(&digits);
1324                }
1325            }
1326        }
1327        out
1328    }
1329}
1330
1331/// The rib's own frame, which orders the fragments of a split face: the
1332/// thickness axis first (which SIDE of the rib a fragment is on), then along
1333/// the chord from the chain's start, then along the growth direction.
1334struct RibFrame {
1335    origin: Vec3,
1336    thickness: Vec3,
1337    chord: Vec3,
1338    growth: Vec3,
1339    /// Coordinates closer than this are equal (a fragment's position is
1340    /// recomputed by every rebuild; its last bits are not its identity).
1341    quantum: f64,
1342}
1343
1344/// Replace every source token on `fused` with the face's final name.
1345///
1346/// A source face that reaches the result as ONE face keeps its name. A face
1347/// that arrives in several fragments keeps its name on ONE of them and names
1348/// the rest after the rib, by position in the rib's own frame rather than by
1349/// the order the boolean met them or by their sizes (both change with edits
1350/// that have nothing to do with the rib — moving a hole from one half of a
1351/// floor to the other swaps which half is larger):
1352///
1353/// - a PART face split by the rib: its fragments are sorted by side (the rib's
1354///   A side, straddling, then its B side), then along the chord, then along the
1355///   growth direction. The first keeps the part's name; every other is
1356///   `{id}:{name}_{side}{k}`, `k` counting that side's fragments from 1 in the
1357///   same order — so a floor a rib crosses stays `Floor` on the A side and
1358///   becomes `{id}:Floor_B1` on the B side.
1359/// - one of the rib's OWN faces in several pieces (the part interrupts the rib):
1360///   along the chord, then the growth direction; the first keeps the name and
1361///   the others are `{name}_2`, `{name}_3`, …
1362///
1363/// A part face that had no name keeps none.
1364fn restamp_rib_faces(fused: &mut BrepSolid, sources: &FaceSources, feature: &str, frame: &RibFrame) {
1365    use std::collections::{BTreeMap, HashMap};
1366    let points: HashMap<u64, Vec3> = fused.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
1367    let ends: HashMap<u64, (u64, u64)> = fused
1368        .edges
1369        .iter()
1370        .map(|edge| (edge.id, (edge.start_vertex_id, edge.end_vertex_id)))
1371        .collect();
1372    let quantise = |value: f64| (value / frame.quantum).round() as i64;
1373    // (shell, face) positions per source, each with its sort key.
1374    let mut groups: BTreeMap<(char, usize), Vec<((i64, i64, i64), (usize, usize))>> = BTreeMap::new();
1375    for (shell_index, shell) in fused.shells.iter().enumerate() {
1376        for (face_index, face) in shell.faces.iter().enumerate() {
1377            let Some(source) = face.name.as_deref().and_then(FaceSources::parse) else {
1378                continue;
1379            };
1380            let mut ids: Vec<u64> = face
1381                .loops
1382                .iter()
1383                .flat_map(|loop_record| &loop_record.coedges)
1384                .filter_map(|coedge| ends.get(&coedge.edge_id))
1385                .flat_map(|(start, end)| [*start, *end])
1386                .collect();
1387            ids.sort_unstable();
1388            ids.dedup();
1389            let mut centre = Vec3::default();
1390            for id in &ids {
1391                if let Some(point) = points.get(id) {
1392                    centre = centre.add(*point);
1393                }
1394            }
1395            let centre = centre.scale(1.0 / ids.len().max(1) as f64).sub(frame.origin);
1396            let key = (
1397                quantise(centre.dot(frame.thickness)),
1398                quantise(centre.dot(frame.chord)),
1399                quantise(centre.dot(frame.growth)),
1400            );
1401            groups.entry(source).or_default().push((key, (shell_index, face_index)));
1402        }
1403    }
1404    for ((kind, index), mut fragments) in groups {
1405        let original = sources.name(kind, index).map(str::to_string);
1406        let side = |key: &(i64, i64, i64)| match key.0.signum() {
1407            -1 => ('A', 0),
1408            0 => ('M', 1),
1409            _ => ('B', 2),
1410        };
1411        let named: Vec<((usize, usize), Option<String>)> = match &original {
1412            None => fragments.into_iter().map(|(_, at)| (at, None)).collect(),
1413            Some(name) if fragments.len() == 1 => vec![(fragments[0].1, Some(name.clone()))],
1414            Some(name) if kind == RIB_TOKEN => {
1415                fragments.sort_by_key(|(key, _)| (key.1, key.2, key.0));
1416                fragments
1417                    .into_iter()
1418                    .enumerate()
1419                    .map(|(rank, (_, at))| {
1420                        (at, Some(if rank == 0 { name.clone() } else { format!("{name}_{}", rank + 1) }))
1421                    })
1422                    .collect()
1423            }
1424            Some(name) => {
1425                fragments.sort_by_key(|(key, _)| (side(key).1, key.1, key.2, key.0));
1426                let mut per_side: BTreeMap<char, usize> = BTreeMap::new();
1427                fragments
1428                    .into_iter()
1429                    .enumerate()
1430                    .map(|(rank, (key, at))| {
1431                        let letter = side(&key).0;
1432                        let ordinal = per_side.entry(letter).or_insert(0);
1433                        *ordinal += 1;
1434                        let spelled = if rank == 0 {
1435                            name.clone()
1436                        } else {
1437                            format!("{feature}:{name}_{letter}{ordinal}")
1438                        };
1439                        (at, Some(spelled))
1440                    })
1441                    .collect()
1442            }
1443        };
1444        for ((shell_index, face_index), name) in named {
1445            fused.shells[shell_index].faces[face_index].name = name;
1446        }
1447    }
1448}