Skip to main content

brepkit_operations/
shell_op.rs

1//! Shell (hollow/offset) operation for creating thin-walled solids.
2//!
3//! Offsets faces of a solid inward to create a hollow shell with
4//! uniform wall thickness. Optionally removes specified faces to
5//! create openings.
6
7use std::collections::{HashMap, HashSet};
8
9use brepkit_math::tolerance::Tolerance;
10use brepkit_math::vec::{Point3, Vec3};
11use brepkit_topology::Topology;
12use brepkit_topology::face::{FaceId, FaceSurface};
13use brepkit_topology::solid::SolidId;
14
15use crate::boolean::{FaceSpec, assemble_solid_mixed};
16use crate::dot_normal_point;
17
18/// Compute the inner vertex position using miter-vector offset.
19///
20/// Given a vertex with normals from adjacent faces, solves for the offset
21/// direction that satisfies `m · n_i = 1` for all non-open face normals
22/// (open face normals contribute 0). The inner position is:
23///   `inner = outer - thickness * m`
24///
25/// For 3 linearly independent normals, this is equivalent to 3-plane
26/// intersection. For 2 normals, it produces the least-norm miter (the
27/// shortest offset vector satisfying both constraints). For 1 normal,
28/// it offsets along that normal.
29fn compute_miter_offset(outer: Point3, unique_normals: &[(Vec3, bool)], thickness: f64) -> Point3 {
30    // Build system: for each unique normal, m · n_i = weight_i
31    // where weight_i = 1.0 for non-open faces, 0.0 for open faces.
32    let mut normals: Vec<Vec3> = Vec::new();
33    let mut weights: Vec<f64> = Vec::new();
34
35    for &(n, is_open) in unique_normals {
36        normals.push(n);
37        weights.push(if is_open { 0.0 } else { 1.0 });
38    }
39
40    let miter = match normals.len() {
41        0 => return outer,
42        1 => {
43            // Single normal: offset along it.
44            normals[0] * weights[0]
45        }
46        2 => {
47            // Two normals: least-norm solution of [n1; n2] · m = [w1; w2].
48            // m = N^T (N N^T)^{-1} w
49            let n1 = normals[0];
50            let n2 = normals[1];
51            let w1 = weights[0];
52            let w2 = weights[1];
53
54            let g11 = n1.dot(n1);
55            let g12 = n1.dot(n2);
56            let g22 = n2.dot(n2);
57            let det = g11 * g22 - g12 * g12;
58
59            if det.abs() < 1e-12 {
60                // Nearly parallel normals: just use the first non-open one.
61                if w1 > 0.5 { n1 * w1 } else { n2 * w2 }
62            } else {
63                let inv_det = 1.0 / det;
64                let a1 = (g22 * w1 - g12 * w2) * inv_det;
65                let a2 = (-g12 * w1 + g11 * w2) * inv_det;
66                n1 * a1 + n2 * a2
67            }
68        }
69        _ => {
70            // Three or more normals: use the first 3 linearly independent
71            // normals and solve via Cramer's rule (3-plane intersection).
72            let n1 = normals[0];
73            let n2 = normals[1];
74            let n3 = normals[2];
75            let w1 = weights[0];
76            let w2 = weights[1];
77            let w3 = weights[2];
78
79            let n2_cross_n3 = n2.cross(n3);
80            let det = n1.dot(n2_cross_n3);
81
82            if det.abs() < 1e-12 {
83                // Degenerate: fall back to 2-normal solution with first two.
84                let g11 = n1.dot(n1);
85                let g12 = n1.dot(n2);
86                let g22 = n2.dot(n2);
87                let d2 = g11 * g22 - g12 * g12;
88                if d2.abs() < 1e-12 {
89                    n1 * w1
90                } else {
91                    let inv = 1.0 / d2;
92                    let a1 = (g22 * w1 - g12 * w2) * inv;
93                    let a2 = (-g12 * w1 + g11 * w2) * inv;
94                    n1 * a1 + n2 * a2
95                }
96            } else {
97                let n3_cross_n1 = n3.cross(n1);
98                let n1_cross_n2 = n1.cross(n2);
99                let inv_det = 1.0 / det;
100                let mx =
101                    (w1 * n2_cross_n3.x() + w2 * n3_cross_n1.x() + w3 * n1_cross_n2.x()) * inv_det;
102                let my =
103                    (w1 * n2_cross_n3.y() + w2 * n3_cross_n1.y() + w3 * n1_cross_n2.y()) * inv_det;
104                let mz =
105                    (w1 * n2_cross_n3.z() + w2 * n3_cross_n1.z() + w3 * n1_cross_n2.z()) * inv_det;
106                Vec3::new(mx, my, mz)
107            }
108        }
109    };
110
111    Point3::new(
112        outer.x() - thickness * miter.x(),
113        outer.y() - thickness * miter.y(),
114        outer.z() - thickness * miter.z(),
115    )
116}
117
118/// Create a hollow shell from a solid by offsetting faces inward.
119///
120/// Each face is offset inward by `thickness` along its outward normal.
121/// Supports planar, NURBS, and analytic surface faces.
122/// If `open_faces` is non-empty, those faces are removed from both the
123/// outer and inner shells, creating openings.
124///
125/// # Errors
126///
127/// Returns an error if:
128/// - `thickness` is non-positive
129/// - Any face in `open_faces` is not part of the solid
130/// - Face offset fails (e.g., negative radius for curved surfaces)
131/// - The resulting shell is degenerate
132#[allow(clippy::too_many_lines)]
133pub fn shell(
134    topo: &mut Topology,
135    solid: SolidId,
136    thickness: f64,
137    open_faces: &[FaceId],
138) -> Result<SolidId, crate::OperationsError> {
139    let tol = Tolerance::new();
140
141    if thickness <= tol.linear {
142        return Err(crate::OperationsError::InvalidInput {
143            reason: format!("shell thickness must be positive, got {thickness}"),
144        });
145    }
146
147    let solid_data = topo.solid(solid)?;
148    let shell_data = topo.shell(solid_data.outer_shell())?;
149    let all_face_ids: Vec<FaceId> = shell_data.faces().to_vec();
150
151    let open_set: HashSet<usize> = open_faces.iter().map(|f| f.index()).collect();
152
153    let solid_face_set: HashSet<usize> = all_face_ids.iter().map(|f| f.index()).collect();
154    for &of in open_faces {
155        if !solid_face_set.contains(&of.index()) {
156            return Err(crate::OperationsError::InvalidInput {
157                reason: format!("face {} is not part of the solid", of.index()),
158            });
159        }
160    }
161
162    // Collect face vertex data (samples curved edges for proper polygons).
163    let mut face_verts: Vec<(FaceId, Vec<Point3>)> = Vec::new();
164    for &fid in &all_face_ids {
165        let verts = crate::boolean::face_polygon(topo, fid)?;
166        face_verts.push((fid, verts));
167    }
168
169    let mut result_specs: Vec<FaceSpec> = Vec::new();
170
171    // ─── Phase 1: Build vertex→normals map using ALL face types ───────────
172    //
173    // For each vertex, collect the outward surface normals from ALL adjacent
174    // faces (planar and non-planar). We use these to compute a miter vector
175    // that gives the correct inner vertex position at the intersection of
176    // all offset surfaces meeting at that vertex.
177    let inv_tol = 1.0 / tol.linear;
178    let quantize_pt = |p: Point3| -> (i64, i64, i64) {
179        (
180            (p.x() * inv_tol).round() as i64,
181            (p.y() * inv_tol).round() as i64,
182            (p.z() * inv_tol).round() as i64,
183        )
184    };
185
186    let mut vertex_normals: HashMap<(i64, i64, i64), Vec<(Vec3, bool)>> = HashMap::new();
187
188    for &(fid, ref verts) in &face_verts {
189        let face = topo.face(fid)?;
190        let is_open = open_set.contains(&fid.index());
191
192        // A convex fillet whose radius the thickness swallows does not offset
193        // to a smaller fillet — it collapses to a sharp edge where the two
194        // NEIGHBOURING offset surfaces meet. Its own normal is useless for
195        // that: at each tangent vertex it equals the neighbour's normal, so
196        // the miter sees one direction, offsets perpendicular only, and the
197        // neighbours overshoot past each other by (thickness - radius) instead
198        // of meeting. Feeding every vertex of the collapsing face BOTH extreme
199        // normals puts the miter on the intersection of the two offset
200        // surfaces, which is exactly the sharp corner.
201        let collapsing = match face.surface() {
202            FaceSurface::Cylinder(cyl) => cyl.radius() - thickness <= tol.linear,
203            _ => false,
204        };
205        let extreme_normals = if collapsing {
206            extreme_face_normals(&face_surface_normals(face, verts))
207        } else {
208            None
209        };
210
211        for v in verts {
212            let (u, v_param) = face.surface().project_point(*v).unwrap_or((0.0, 0.0));
213            let mut normal = face.surface().normal(u, v_param);
214            // Account for the face's reversal flag: when a face is reversed,
215            // the native surface normal points in the wrong direction.
216            if face.is_reversed() {
217                normal = -normal;
218            }
219            let entry = vertex_normals.entry(quantize_pt(*v)).or_default();
220            if let Some((n_a, n_b)) = extreme_normals {
221                entry.push((n_a, is_open));
222                entry.push((n_b, is_open));
223            } else {
224                entry.push((normal, is_open));
225            }
226        }
227    }
228
229    // ─── Phase 2: Compute inner vertex positions via miter vectors ────────
230    //
231    // The miter vector m at a vertex satisfies m · n_i = 1 for each unique
232    // face normal n_i. The inner position is: inner = outer - thickness * m.
233    // This correctly handles vertices where 2 or 3 offset surfaces intersect
234    // (including non-planar surfaces like cylinders at tangent points).
235    //
236    // For open faces, the offset distance is 0 (the rim vertex stays on the
237    // original plane), so we use n_i with a weight of 0 in that direction.
238    let mut inner_pos: HashMap<(i64, i64, i64), Point3> = HashMap::new();
239
240    for (&key, normals) in &vertex_normals {
241        // Deduplicate nearly-parallel normals, keeping track of whether
242        // each unique normal is offset (non-open) or stays (open).
243        let mut unique: Vec<(Vec3, bool)> = Vec::new();
244        for &(n, is_open) in normals {
245            // Use cosine similarity to deduplicate nearly-parallel normals.
246            // At tangent points (where a flat face meets a curved face),
247            // normals can differ by small amounts that still cause near-singular
248            // miter vectors if treated as independent.
249            let dominated = unique.iter_mut().any(|(un, existing_open)| {
250                let dot = un.dot(n);
251                if dot > 0.995 {
252                    // Nearly parallel — merge. Prefer the non-open (offset) variant.
253                    if *existing_open && !is_open {
254                        *un = n;
255                        *existing_open = false;
256                    }
257                    true
258                } else {
259                    false
260                }
261            });
262            if !dominated {
263                unique.push((n, is_open));
264            }
265        }
266
267        // Reconstruct the outer point from the quantized key.
268        let outer_pt = Point3::new(
269            key.0 as f64 / inv_tol,
270            key.1 as f64 / inv_tol,
271            key.2 as f64 / inv_tol,
272        );
273
274        // Build the miter offset: solve N · m = b where b_i = thickness
275        // for non-open faces, 0 for open faces.
276        let inner = compute_miter_offset(outer_pt, &unique, thickness);
277        inner_pos.insert(key, inner);
278    }
279
280    // Outer faces: the non-open faces kept as-is.
281    for &(fid, ref verts) in &face_verts {
282        if open_set.contains(&fid.index()) {
283            continue;
284        }
285        let face = topo.face(fid)?;
286        match face.surface() {
287            FaceSurface::Plane { normal, d } => {
288                result_specs.push(FaceSpec::Planar {
289                    vertices: verts.clone(),
290                    normal: *normal,
291                    d: *d,
292                    inner_wires: vec![],
293                });
294            }
295            FaceSurface::Cylinder(cyl) => {
296                // Use CylindricalFace to preserve arc edges (Circle EdgeCurve)
297                // so that tessellation and volume computation remain accurate.
298                let wire = topo.wire(face.outer_wire())?;
299                let has_closed_edge = wire
300                    .edges()
301                    .iter()
302                    .any(|oe| topo.edge(oe.edge()).is_ok_and(|e| e.start() == e.end()));
303                if has_closed_edge {
304                    result_specs.push(FaceSpec::Surface {
305                        vertices: verts.clone(),
306                        surface: FaceSurface::Cylinder(cyl.clone()),
307                        reversed: false,
308                        inner_wires: vec![],
309                    });
310                } else {
311                    result_specs.push(FaceSpec::CylindricalFace {
312                        vertices: verts.clone(),
313                        cylinder: cyl.clone(),
314                        reversed: false,
315                        inner_wires: vec![],
316                    });
317                }
318            }
319            other => {
320                result_specs.push(FaceSpec::Surface {
321                    vertices: verts.clone(),
322                    surface: other.clone(),
323                    reversed: false,
324                    inner_wires: vec![],
325                });
326            }
327        }
328    }
329
330    // ─── Phase 4: Inner faces (offset of non-open faces) ──────────────────
331    //
332    // All inner vertex positions come from the miter vector computation in
333    // Phase 2. This ensures watertight geometry at ALL junctions, including
334    // where planar faces meet cylindrical faces at tangent points.
335
336    for &(fid, ref outer_verts) in &face_verts {
337        if open_set.contains(&fid.index()) {
338            continue;
339        }
340        let face = topo.face(fid)?;
341
342        // Reversed winding gives the inner face an inward-pointing normal.
343        let inner_verts: Vec<Point3> = outer_verts
344            .iter()
345            .map(|v| inner_pos.get(&quantize_pt(*v)).copied().unwrap_or(*v))
346            .rev()
347            .collect();
348
349        match face.surface() {
350            FaceSurface::Plane { normal, .. } => {
351                let inner_normal = -*normal;
352                let inner_d = dot_normal_point(inner_normal, inner_verts[0]);
353                result_specs.push(FaceSpec::Planar {
354                    vertices: inner_verts,
355                    normal: inner_normal,
356                    d: inner_d,
357                    inner_wires: vec![],
358                });
359            }
360            FaceSurface::Cylinder(cyl) => {
361                let new_radius = cyl.radius() - thickness;
362                if new_radius <= tol.linear {
363                    // The thickness swallows the fillet: the inner surface is
364                    // not a smaller cylinder but the sharp chamfer where the
365                    // two neighbouring offset walls meet. Without this face
366                    // the inner shell has a corner-wide gap, and the spec
367                    // assembler can only close it by threading another face's
368                    // wire through the cavity (edge-paired but geometrically
369                    // degenerate, which aborts the next boolean's assembly).
370                    // The strip's corners are exactly this face's WIRE
371                    // vertices mapped through the miter positions: both
372                    // tangent lines already carry the extreme-normal miter.
373                    let wire = topo.wire(face.outer_wire())?;
374                    let mut strip: Vec<Point3> = Vec::new();
375                    for oe in wire.edges() {
376                        let e = topo.edge(oe.edge())?;
377                        let v = topo.vertex(oe.oriented_start(e))?.point();
378                        let p = inner_pos.get(&quantize_pt(v)).copied().unwrap_or(v);
379                        if strip.last().is_none_or(|q| (*q - p).length() > tol.linear) {
380                            strip.push(p);
381                        }
382                    }
383                    if strip.len() > 2 && (strip[0] - strip[strip.len() - 1]).length() <= tol.linear
384                    {
385                        strip.pop();
386                    }
387                    if strip.len() >= 3
388                        && let Some((n_a, n_b)) =
389                            extreme_face_normals(&face_surface_normals(face, outer_verts))
390                        && let Ok(outward) = (n_a + n_b).normalize()
391                    {
392                        strip.reverse();
393                        let inner_normal = -outward;
394                        let inner_d = dot_normal_point(inner_normal, strip[0]);
395                        result_specs.push(FaceSpec::Planar {
396                            vertices: strip,
397                            normal: inner_normal,
398                            d: inner_d,
399                            inner_wires: vec![],
400                        });
401                    }
402                } else if let Ok(new_cyl) = brepkit_math::surfaces::CylindricalSurface::new(
403                    cyl.origin(),
404                    cyl.axis(),
405                    new_radius,
406                ) {
407                    // Full-circle cylinders: use Surface (the dense sample
408                    // polygon from face_polygon contains seam-duplicate
409                    // vertices that CylindricalFace can't handle cleanly).
410                    // Partial-arc cylinders: use CylindricalFace to create
411                    // Circle edges that preserve angular range info.
412                    let wire = topo.wire(face.outer_wire())?;
413                    let has_closed_edge = wire
414                        .edges()
415                        .iter()
416                        .any(|oe| topo.edge(oe.edge()).is_ok_and(|e| e.start() == e.end()));
417                    if has_closed_edge {
418                        result_specs.push(FaceSpec::Surface {
419                            vertices: inner_verts,
420                            surface: FaceSurface::Cylinder(new_cyl),
421                            reversed: true,
422                            inner_wires: vec![],
423                        });
424                    } else {
425                        result_specs.push(FaceSpec::CylindricalFace {
426                            vertices: inner_verts,
427                            cylinder: new_cyl,
428                            reversed: true,
429                            inner_wires: vec![],
430                        });
431                    }
432                }
433            }
434            FaceSurface::Cone(_cone) => {
435                let inner_fid = crate::offset_face::offset_face(topo, fid, -thickness, 8)?;
436                let inner_face = topo.face(inner_fid)?;
437                result_specs.push(FaceSpec::Surface {
438                    vertices: inner_verts,
439                    surface: inner_face.surface().clone(),
440                    reversed: true,
441                    inner_wires: vec![],
442                });
443            }
444            FaceSurface::Sphere(sphere) => {
445                let new_r = sphere.radius() - thickness;
446                if new_r <= 0.0 {
447                    return Err(crate::OperationsError::InvalidInput {
448                        reason: format!(
449                            "shell thickness ({thickness}) exceeds sphere radius ({}), \
450                             resulting inner sphere would have non-positive radius ({new_r})",
451                            sphere.radius(),
452                        ),
453                    });
454                }
455                let new_sph = brepkit_math::surfaces::SphericalSurface::new(sphere.center(), new_r)
456                    .map_err(crate::OperationsError::Math)?;
457                result_specs.push(FaceSpec::Surface {
458                    vertices: inner_verts,
459                    surface: FaceSurface::Sphere(new_sph),
460                    reversed: true,
461                    inner_wires: vec![],
462                });
463            }
464            FaceSurface::Nurbs(_) | FaceSurface::Torus(_) => {
465                let inner_fid = crate::offset_face::offset_face(topo, fid, -thickness, 8)?;
466                let inner_face = topo.face(inner_fid)?;
467                result_specs.push(FaceSpec::Surface {
468                    vertices: inner_verts,
469                    surface: inner_face.surface().clone(),
470                    reversed: true,
471                    inner_wires: vec![],
472                });
473            }
474        }
475    }
476
477    // ─── Phase 5: Assemble outer + inner faces, then close rim ─────────────
478    //
479    // Instead of creating disconnected rim quads (which don't share edges
480    // with the outer/inner faces), we first assemble the outer + inner faces
481    // into a solid with open boundaries, then find the boundary edges and
482    // create a single annular rim face per open face. This guarantees edge
483    // sharing and produces a manifold shell.
484
485    if result_specs.is_empty() {
486        return Err(crate::OperationsError::InvalidInput {
487            reason: "shell operation produced no faces".into(),
488        });
489    }
490
491    let solid = assemble_solid_mixed(topo, &result_specs, tol)?;
492
493    let edge_face_map = brepkit_topology::explorer::edge_to_face_map(topo, solid)?;
494    let mut boundary_edge_ids: Vec<brepkit_topology::edge::EdgeId> = Vec::new();
495    for (&edge_idx, faces) in &edge_face_map {
496        if faces.len() == 1
497            && let Some(eid) = topo.edge_id_from_index(edge_idx)
498        {
499            boundary_edge_ids.push(eid);
500        }
501    }
502
503    if boundary_edge_ids.is_empty() {
504        // No open boundary — shell is already closed (no open faces, or all faces present).
505        return Ok(solid);
506    }
507
508    // `edge_to_face_map` iterates in hash order, so without this sort the rim
509    // loop's starting edge — and with it the rim face's wire origin, which
510    // downstream consumers use as a plane-frame anchor — varied run to run.
511    boundary_edge_ids.sort_by_key(|e| e.index());
512
513    // Determine the oriented direction of each boundary edge relative to its single face.
514    // The rim face must use the OPPOSITE orientation so the edge is shared correctly.
515    let mut boundary_oriented: Vec<brepkit_topology::wire::OrientedEdge> = Vec::new();
516    for &eid in &boundary_edge_ids {
517        let face_id = edge_face_map[&eid.index()][0];
518        let face = topo.face(face_id)?;
519        // The rim must traverse the shared edge opposite to the owner's
520        // EFFECTIVE sense — stored direction XOR the face's reversal flag —
521        // not merely its stored direction (a reversed cavity face traverses
522        // its wire backwards).
523        let rev = face.is_reversed();
524        let wire = topo.wire(face.outer_wire())?;
525        let mut found = false;
526        for oe in wire.edges() {
527            if oe.edge() == eid {
528                boundary_oriented.push(brepkit_topology::wire::OrientedEdge::new(
529                    eid,
530                    oe.is_forward() == rev,
531                ));
532                found = true;
533                break;
534            }
535        }
536        if !found {
537            for &iw_id in face.inner_wires() {
538                let iw = topo.wire(iw_id)?;
539                for oe in iw.edges() {
540                    if oe.edge() == eid {
541                        boundary_oriented.push(brepkit_topology::wire::OrientedEdge::new(
542                            eid,
543                            oe.is_forward() == rev,
544                        ));
545                        found = true;
546                        break;
547                    }
548                }
549                if found {
550                    break;
551                }
552            }
553            if !found {
554                // Fallback: use forward orientation.
555                boundary_oriented.push(brepkit_topology::wire::OrientedEdge::new(eid, true));
556            }
557        }
558    }
559
560    let loops = sort_edges_into_loops(topo, &boundary_oriented)?;
561
562    if loops.len() < 2 {
563        // Need at least 2 loops (outer + inner) for an annular face.
564        // If only 1 loop, something is wrong — return the solid as-is.
565        return Ok(solid);
566    }
567
568    // Classify loops: the outer loop has larger average distance from centroid.
569    let mut centroid = Vec3::new(0.0, 0.0, 0.0);
570    let mut vert_count = 0.0;
571    let mut rim_z = 0.0_f64;
572    for oe in &boundary_oriented {
573        let edge = topo.edge(oe.edge())?;
574        let p = topo.vertex(edge.start())?.point();
575        centroid += Vec3::new(p.x(), p.y(), p.z());
576        rim_z += p.z();
577        vert_count += 1.0;
578    }
579    if vert_count > 0.0 {
580        centroid = centroid * (1.0 / vert_count);
581        rim_z /= vert_count;
582    }
583
584    let mut loop_radii: Vec<(usize, f64)> = Vec::new();
585    for (i, lp) in loops.iter().enumerate() {
586        let mut avg_r = 0.0;
587        let mut n = 0.0;
588        for oe in lp {
589            let edge = topo.edge(oe.edge())?;
590            let p = topo.vertex(edge.start())?.point();
591            let dx = p.x() - centroid.x();
592            let dy = p.y() - centroid.y();
593            avg_r += (dx * dx + dy * dy).sqrt();
594            n += 1.0;
595        }
596        if n > 0.0 {
597            avg_r /= n;
598        }
599        loop_radii.push((i, avg_r));
600    }
601    loop_radii.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
602
603    // Largest loop is the outer wire, all others are inner wires (holes).
604    let outer_loop_idx = loop_radii[0].0;
605
606    let outer_wire = brepkit_topology::wire::Wire::new(loops[outer_loop_idx].clone(), true)
607        .map_err(crate::OperationsError::Topology)?;
608    let outer_wire_id = topo.add_wire(outer_wire);
609
610    let mut inner_wire_ids = Vec::new();
611    for &(idx, _) in &loop_radii[1..] {
612        let inner_wire = brepkit_topology::wire::Wire::new(loops[idx].clone(), true)
613            .map_err(crate::OperationsError::Topology)?;
614        inner_wire_ids.push(topo.add_wire(inner_wire));
615    }
616
617    // Rim face normal: pointing away from solid center (outward at the rim).
618    // For a top-opened shell, this is typically +Z or -Z.
619    // Compute from the open face's normal.
620    let rim_normal = {
621        let mut n = Vec3::new(0.0, 0.0, 1.0);
622        for &(fid, _) in &face_verts {
623            if open_set.contains(&fid.index())
624                && let Ok(f) = topo.face(fid)
625                && let FaceSurface::Plane { normal, .. } = f.surface()
626            {
627                // The rim normal should point in the same direction as the
628                // removed face's outward normal (away from solid interior).
629                n = if f.is_reversed() { -*normal } else { *normal };
630                break;
631            }
632        }
633        n
634    };
635
636    let rim_d =
637        rim_normal.x() * centroid.x() + rim_normal.y() * centroid.y() + rim_normal.z() * rim_z;
638    let rim_face = brepkit_topology::face::Face::new(
639        outer_wire_id,
640        inner_wire_ids,
641        FaceSurface::Plane {
642            normal: rim_normal,
643            d: rim_d,
644        },
645    );
646    let rim_face_id = topo.add_face(rim_face);
647
648    let solid_data = topo.solid(solid)?;
649    let shell_id = solid_data.outer_shell();
650    let shell = topo.shell(shell_id)?;
651    let mut new_faces: Vec<FaceId> = shell.faces().to_vec();
652    new_faces.push(rim_face_id);
653    let new_shell =
654        brepkit_topology::shell::Shell::new(new_faces).map_err(crate::OperationsError::Topology)?;
655    *topo.shell_mut(shell_id)? = new_shell;
656
657    Ok(solid)
658}
659
660/// Sort oriented edges into connected loops.
661///
662/// Takes a set of oriented boundary edges and groups them into closed loops
663/// by following edge connectivity (end vertex → start vertex of next edge).
664fn sort_edges_into_loops(
665    topo: &Topology,
666    edges: &[brepkit_topology::wire::OrientedEdge],
667) -> Result<Vec<Vec<brepkit_topology::wire::OrientedEdge>>, crate::OperationsError> {
668    use brepkit_topology::vertex::VertexId;
669
670    if edges.is_empty() {
671        return Ok(Vec::new());
672    }
673
674    // Chain UNDIRECTED and assign each edge's orientation from the chain
675    // direction. The old chaining followed the given orientations strictly, so
676    // a boundary whose faces traverse their shared rim in mixed senses (the
677    // corrected cavity wires vs the outer wall wires) dead-ended into open
678    // wires. The first edge's given orientation seeds each loop's direction,
679    // preserving the rim winding convention.
680    let mut endpoints: Vec<(VertexId, VertexId)> = Vec::with_capacity(edges.len());
681    let mut incident: HashMap<usize, Vec<usize>> = HashMap::new();
682    for (i, oe) in edges.iter().enumerate() {
683        let edge = topo.edge(oe.edge())?;
684        let (sv, ev) = if oe.is_forward() {
685            (edge.start(), edge.end())
686        } else {
687            (edge.end(), edge.start())
688        };
689        incident.entry(sv.index()).or_default().push(i);
690        incident.entry(ev.index()).or_default().push(i);
691        endpoints.push((sv, ev));
692    }
693
694    let mut used = vec![false; edges.len()];
695    let mut loops = Vec::new();
696
697    while let Some(start_idx) = used.iter().position(|&u| !u) {
698        let mut current_loop = Vec::new();
699        used[start_idx] = true;
700        current_loop.push(edges[start_idx]);
701        let chain_start = endpoints[start_idx].0.index();
702        let mut at = endpoints[start_idx].1.index();
703
704        let mut closed = at == chain_start;
705        while at != chain_start {
706            let mut next: Option<(usize, bool)> = None;
707            if let Some(candidates) = incident.get(&at) {
708                for &idx in candidates {
709                    if used[idx] {
710                        continue;
711                    }
712                    let (sv, ev) = endpoints[idx];
713                    if sv.index() == at {
714                        next = Some((idx, true));
715                    } else if ev.index() == at {
716                        next = Some((idx, false));
717                    } else {
718                        continue;
719                    }
720                    break;
721                }
722            }
723            let Some((idx, as_given)) = next else {
724                break; // Broken chain — give up on this loop.
725            };
726            used[idx] = true;
727            let oe = edges[idx];
728            let oriented = if as_given {
729                oe
730            } else {
731                brepkit_topology::wire::OrientedEdge::new(oe.edge(), !oe.is_forward())
732            };
733            current_loop.push(oriented);
734            let (sv, ev) = endpoints[idx];
735            at = if as_given { ev.index() } else { sv.index() };
736            closed = at == chain_start;
737        }
738
739        // A partial (unclosed) chain would make the rim face carry an open
740        // wire; drop it and leave the boundary open for validation to flag.
741        if closed && !current_loop.is_empty() {
742            loops.push(current_loop);
743        } else if !current_loop.is_empty() {
744            log::warn!(
745                "shell rim: dropping an unclosed boundary chain of {} edge(s)",
746                current_loop.len()
747            );
748        }
749    }
750
751    Ok(loops)
752}
753
754#[cfg(test)]
755mod tests;
756
757/// Outward normals of `face` at each of `verts`, honouring the reversal flag.
758fn face_surface_normals(face: &brepkit_topology::face::Face, verts: &[Point3]) -> Vec<Vec3> {
759    verts
760        .iter()
761        .map(|v| {
762            let (u, vp) = face.surface().project_point(*v).unwrap_or((0.0, 0.0));
763            let n = face.surface().normal(u, vp);
764            if face.is_reversed() { -n } else { n }
765        })
766        .collect()
767}
768
769/// The two most widely separated normals in `normals` (the ends of a fillet's
770/// angular sweep), or `None` if they are all effectively parallel — a face that
771/// spans no angle has no sharp corner to collapse to.
772fn extreme_face_normals(normals: &[Vec3]) -> Option<(Vec3, Vec3)> {
773    let mut best: Option<(f64, Vec3, Vec3)> = None;
774    for (i, a) in normals.iter().enumerate() {
775        for b in &normals[i + 1..] {
776            let d = a.dot(*b);
777            if best.is_none_or(|(bd, _, _)| d < bd) {
778                best = Some((d, *a, *b));
779            }
780        }
781    }
782    // cos > 0.999 is under a couple of degrees: not a real corner.
783    best.filter(|&(d, _, _)| d < 0.999).map(|(_, a, b)| (a, b))
784}