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