Skip to main content

brep_kernel/offset/offset_shell/
solid.rs

1//! Whole-solid offset (D3).
2//!
3//! `offset_solid` runs the shell pipeline in its WHOLE-BODY mode
4//! (`Body::Whole`): every face is retained, there is no opening and no wall,
5//! the source's own faces are not assembled, and the body is the offset
6//! carriers alone, closed at every sharp junction by the extend-and-intersect
7//! the pipeline already does for a shell's retained faces.
8//!
9//! The sign is the natural one: a positive distance grows the body along its
10//! outward normals, a negative one shrinks it. `offset_shell` takes the
11//! opposite sign (positive = inward wall), so the pipeline is asked for
12//! `-distance`.
13//!
14//! `OffsetCorners::Rounded` (D3) is the true parallel body, the Minkowski sum
15//! with a ball of radius `|d|`: the sharp body with every convex edge image
16//! (outward) or concave edge image (inward) rolled by the blend network at
17//! radius `|d|`, which is what puts the exact cylinder or torus cap on each
18//! straight or circular edge and the corner ball at each vertex. The blend's
19//! constructions are reused through `fillet_edges`, never rebuilt here.
20use super::debug_enabled;
21use super::pipeline::{offset_body, Body};
22use super::{OffsetFaceRole, OffsetShellFaceImageRecord, OffsetShellResultRecord};
23use crate::classification::PolygonClass;
24use crate::topology::{BrepSolid, EdgeRecord, FaceRecord};
25use crate::{
26    boolean_operation, fillet_edges, project_point_to_surface, BooleanOperation, BooleanOptions,
27    KernelRefusal, KernelStage, OrRefuse, RefusalClass, Vec2, Vec3,
28};
29use rustc_hash::FxHashMap as HashMap;
30use serde::{Deserialize, Serialize};
31
32/// How the offset body closes the joins between offset faces.
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum OffsetCorners {
36    #[default]
37    Sharp,
38    /// The exact parallel body: convex joins (outward) or concave joins
39    /// (inward) carry the rolling ball's cylinder, torus or sphere.
40    Rounded,
41}
42
43/// Offset every face of `source` by `distance` (positive grows) and join the
44/// offset faces into a closed body.
45pub fn offset_solid(
46    source: &BrepSolid,
47    distance: f64,
48    corners: OffsetCorners,
49) -> Result<OffsetShellResultRecord, KernelRefusal> {
50    if corners == OffsetCorners::Rounded && distance > 0.0 {
51        if let Some(grown) = grown_across_reflex_edges(source, distance) {
52            return Ok(grown);
53        }
54    }
55    let sharp = match offset_body(source, &[], -distance, Body::Whole, "offset_solid") {
56        Ok(sharp) => sharp,
57        Err(error) => {
58            if corners == OffsetCorners::Rounded {
59                if let Some(grown) = grown_by_pieces(source, distance) {
60                    return Ok(grown);
61                }
62            }
63            return Err(error);
64        }
65    };
66    match corners {
67        OffsetCorners::Sharp => Ok(sharp),
68        OffsetCorners::Rounded => match round_offset_joins(sharp, distance) {
69            Ok(rounded) => Ok(rounded),
70            // A mitred body cannot carry the ball over a vertex of degree four
71            // or more; a convex polyhedron's parallel body is built directly.
72            Err(error) => match crate::blend::convex_parallel::convex_polyhedron_parallel(source, distance) {
73                Ok(Some((solid, images))) if solid.validate().is_empty() => {
74                    let face_images = images
75                        .into_iter()
76                        .map(|image| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: image.unwrap_or(0) })
77                        .collect();
78                    Ok(OffsetShellResultRecord { solid, face_images })
79                }
80                _ => Err(error),
81            },
82        },
83    }
84}
85
86/// `BREP_OFFSET_GROW_BY_PIECES` (default on; `=0` restores): [`grown_by_pieces`].
87fn grow_by_pieces_enabled() -> bool {
88    switch!("BREP_OFFSET_GROW_BY_PIECES", true)
89}
90
91
92/// The ROUNDED growth of a body whose own offset body the pipeline could not
93/// assemble, as the union of its pieces' growths: the parallel body of a
94/// union is the union of the parallel bodies, `(A ∪ B) ⊕ D = (A ⊕ D) ∪
95/// (B ⊕ D)`, for any pieces whose union is the body.
96///
97/// Cause (a drum with a groove 1.6 wide grown by 1, brep-bench
98/// offset/grooved-drum-grown): the groove's walls offset across each other,
99/// "a skin partly shadowed that way is not yet assembled", and even assembled
100/// the sharp body would have no groove left to round, while the case's notch
101/// is the two tori about the groove's outer rims. Cut at the planes of its
102/// planar faces the drum is three cylinders; each grows by the pipeline's own
103/// rounded offset, and their union carries the notch where the two rim tori
104/// cross.
105///
106/// The cuts are chosen by [`thin_pair_planes`] (the planes parallel to a pair
107/// of planar faces whose offsets cross), each kept only where it divides a
108/// piece (`split_solid_by_plane`). A SHRINK runs the same construction on the
109/// body's complement in a frame box ([`shrunk_by_pieces`]). Every piece must
110/// grow and the union must validate; anything else declines and the
111/// pipeline's own refusal stands. The faces are all `Offset` images with
112/// `source_face_id` 0, as the rounded caps are: a face of the union may carry
113/// the growth of several source faces' pieces.
114fn grown_by_pieces(source: &BrepSolid, distance: f64) -> Option<OffsetShellResultRecord> {
115    if !grow_by_pieces_enabled() {
116        return None;
117    }
118    match grown_by_pieces_impl(source, distance) {
119        Ok(result) => result,
120        Err(error) => {
121            os_debug!("grow by pieces declined: {error}");
122            None
123        }
124    }
125}
126
127fn grown_by_pieces_impl(source: &BrepSolid, distance: f64) -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
128    if distance < 0.0 {
129        return shrunk_by_pieces(source, -distance);
130    }
131    let Some(sets) = thin_pair_planes(source, distance, true)? else {
132        return Ok(None);
133    };
134    let mut answer = None;
135    for planes in &sets {
136        match grown_union(source, planes, distance) {
137            Ok(Some(solid)) => {
138                answer = Some(solid);
139                break;
140            }
141            Ok(None) => {}
142            Err(error) => os_debug!("grow by pieces: {} plane(s) declined: {error}", planes.len()),
143        }
144    }
145    let Some(solid) = answer else {
146        return Ok(None);
147    };
148    let face_images = solid
149        .shells
150        .iter()
151        .flat_map(|shell| &shell.faces)
152        .map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
153        .collect();
154    Ok(Some(OffsetShellResultRecord { solid, face_images }))
155}
156
157/// Roll the sharp body's joins that the ball of radius `|distance|` rounds:
158/// the convex edges of an outward offset, the concave edges of an inward one.
159/// A body with no such edge is already the parallel body and is returned as
160/// it is. Provenance: a face still on a sharp-body surface keeps that face's
161/// image; a cap (a cylinder, torus or corner ball the blend built, which lies
162/// on no offset face) is an `Offset` image with `source_face_id` 0, because it
163/// rounds a JOIN between source faces and has no single source face.
164fn round_offset_joins(
165    sharp: OffsetShellResultRecord,
166    distance: f64,
167) -> Result<OffsetShellResultRecord, KernelRefusal> {
168    let wanted_convex = distance > 0.0;
169    let midpoints = sharp_join_midpoints(&sharp.solid, wanted_convex)?;
170    if midpoints.is_empty() {
171        return Ok(sharp);
172    }
173    let solid = fillet_edges(&sharp.solid, &midpoints, None, distance.abs(), false, None)
174        .map_err(|error| {
175            error.with_message(|message| {
176                format!(
177                    "offset_solid: the rounded joins of the offset body could not be built \
178                     ({} edge(s) at radius {:.6}): {message}",
179                    midpoints.len(),
180                    distance.abs()
181                )
182            })
183        })?;
184    let operands = sharp
185        .solid
186        .shells
187        .iter()
188        .flat_map(|shell| &shell.faces)
189        .zip(&sharp.face_images)
190        .map(|(face, image)| (face, image.clone()))
191        .collect::<Vec<_>>();
192    let cap = OffsetShellFaceImageRecord {
193        role: OffsetFaceRole::Offset,
194        source_face_id: 0,
195    };
196    let face_images = provenance_by_surface(&solid, &operands, Some(&cap))?;
197    Ok(OffsetShellResultRecord { solid, face_images })
198}
199
200/// Per-face provenance of a body whose faces lie on its operands' surfaces (a
201/// boolean result, a blend's untouched faces): each result face takes the
202/// image of the FIRST operand face whose surface carries an interior point of
203/// it and whose trim contains that point's foot. Operands are matched in the
204/// order given, so a coincident pair (a held face and the opening it copies)
205/// resolves to whichever is listed first. A face on no operand surface takes
206/// `unmatched`, or refuses by name when there is none.
207fn provenance_by_surface(
208    result: &BrepSolid,
209    operands: &[(&FaceRecord, OffsetShellFaceImageRecord)],
210    unmatched: Option<&OffsetShellFaceImageRecord>,
211) -> Result<Vec<OffsetShellFaceImageRecord>, KernelRefusal> {
212    let band = 1e-6 * crate::solid_scale(result).max(1.0);
213    let mut images = Vec::new();
214    for face in result.shells.iter().flat_map(|shell| &shell.faces) {
215        let seeds = super::source_seeds(result, face.id)?;
216        let seed = seeds[0];
217        let point = face.surface.evaluate(seed.x, seed.y).or_refuse(KernelStage::Sew, "evaluate")?;
218        let mut found = None;
219        for (operand, image) in operands {
220            let Ok(foot) = project_point_to_surface(&operand.surface, point) else { continue };
221            if foot.distance > band {
222                continue;
223            }
224            let inside = super::parameter_point_in_face(operand, Vec2 { x: foot.u, y: foot.v }, 1e-8)
225                .is_ok_and(|class| class != PolygonClass::Outside);
226            if inside {
227                found = Some(image.clone());
228                break;
229            }
230        }
231        match found.or_else(|| unmatched.cloned()) {
232            Some(image) => images.push(image),
233            None => {
234                return Err(KernelRefusal::internal(
235                    KernelStage::Sew,
236                    "offset_shell_provenance_lost",
237                    format!(
238                        "offset_shell: result face {} at ({:.4}, {:.4}, {:.4}) lies on no operand face",
239                        face.id, point.x, point.y, point.z
240                    ),
241                ))
242            }
243        }
244    }
245    Ok(images)
246}
247
248/// Midpoints of the SHARP edges of `solid` whose convexity is `convex`: an
249/// edge bordering two different faces whose outward normals differ there, read
250/// at the edge midpoint. Tangent junctions (a blend face meeting its support)
251/// and face-local seams are not joins and are skipped.
252fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
253    // Cosine of the dihedral angle above which two faces read as tangent.
254    const TANGENT_COSINE: f64 = 1.0 - 1e-6;
255    let faces = solid
256        .shells
257        .iter()
258        .flat_map(|shell| &shell.faces)
259        .collect::<Vec<_>>();
260    let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
261    for face in &faces {
262        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
263            uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
264        }
265    }
266    let mut midpoints = Vec::new();
267    for edge in &solid.edges {
268        if edge.degenerate {
269            continue;
270        }
271        let Some(pair) = uses.get(&edge.id) else { continue };
272        if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
273            continue;
274        }
275        if edge_is_convex_join(edge, pair[0], pair[1].0, TANGENT_COSINE)? == Some(convex) {
276            midpoints.push(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Classify, "evaluate")?);
277        }
278    }
279    Ok(midpoints)
280}
281
282/// `Some(true)` for a convex join, `Some(false)` for a concave one, `None` for
283/// a tangent junction. With `t` the direction the first face's coedge runs
284/// along the edge and `n_a` that face's outward normal, `n_a × t` points into
285/// the first face; the join is convex when the second face's outward normal
286/// points away from there.
287fn edge_is_convex_join(
288    edge: &EdgeRecord,
289    (first, first_forward): (&FaceRecord, bool),
290    second: &FaceRecord,
291    tangent_cosine: f64,
292) -> Result<Option<bool>, KernelRefusal> {
293    let t = 0.5 * (edge.t0 + edge.t1);
294    let derivatives = edge.curve.derivatives(t, 1).or_refuse(KernelStage::Classify, "derivatives")?;
295    let point = derivatives[0];
296    let mut tangent = derivatives[1].normalized().or_refuse(KernelStage::Classify, "normalized")?;
297    if !first_forward {
298        tangent = tangent.scale(-1.0);
299    }
300    let normal_a = outward_normal_at(first, point)?;
301    let normal_b = outward_normal_at(second, point)?;
302    if normal_a.dot(normal_b) >= tangent_cosine {
303        return Ok(None);
304    }
305    Ok(Some(normal_b.dot(normal_a.cross(tangent)) < 0.0))
306}
307
308fn outward_normal_at(face: &FaceRecord, point: Vec3) -> Result<Vec3, KernelRefusal> {
309    let foot = project_point_to_surface(&face.surface, point).or_refuse(KernelStage::Classify, "project_point_to_surface")?;
310    super::face_normal(face, foot.u, foot.v)
311}
312
313// ---------------------------------------------------------------------------
314// Shell as a boolean
315// ---------------------------------------------------------------------------
316
317/// The textbook shell: the offset body with the opening faces HELD (copied at
318/// distance zero, so they cancel in the difference), then `source − body` for
319/// an inward shell and `body − source` for an outward one. The walls at the
320/// openings come out of the boolean. `distance` carries `offset_shell`'s sign
321/// (positive inward).
322///
323/// This is the lane `offset_shell` falls back to when its direct construction
324/// refuses by one of its own names (`direct_refusal_takes_the_boolean_lane`);
325/// it is public so the two can be compared on any fixture. The result's
326/// `face_images` are read back from the operands by surface
327/// (`provenance_by_surface`): `Offset` for a face of the body's skins,
328/// `Wall` for a held opening face, `Source` for a face of the source.
329pub fn offset_shell_by_boolean(
330    source: &BrepSolid,
331    opening_face_ids: &[u64],
332    distance: f64,
333) -> Result<OffsetShellResultRecord, KernelRefusal> {
334    if opening_face_ids.is_empty() {
335        return Err(KernelRefusal::input(
336            KernelStage::Collect,
337            "offset_shell_no_opening",
338            "offset_shell: at least one opening face is required",
339        ));
340    }
341    let body = offset_body(source, opening_face_ids, distance, Body::Whole, "offset_shell_by_boolean")
342        .or_else(|error| {
343            if !direct_refusal_takes_the_boolean_lane(&error) {
344                return Err(error);
345            }
346            let result = super::joint_offset::build(source, opening_face_ids, distance);
347            if let Err(reason) = &result {
348                os_debug!("joint offset refused: {reason}");
349            }
350            result.map_err(|_| error)
351        })?;
352    let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
353    let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
354        .map_err(|error| {
355            error.with_message(|message| {
356                format!("offset_shell: the boolean lane's difference could not be built: {message}")
357            })
358        })?;
359    // The body's faces first, so the ring on a held face's plane reads as
360    // that face's `Wall` rather than as the coincident source opening.
361    let mut operands = body
362        .solid
363        .shells
364        .iter()
365        .flat_map(|shell| &shell.faces)
366        .zip(&body.face_images)
367        .map(|(face, image)| (face, image.clone()))
368        .collect::<Vec<_>>();
369    operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
370        (
371            face,
372            OffsetShellFaceImageRecord {
373                role: OffsetFaceRole::Source,
374                source_face_id: face.id,
375            },
376        )
377    }));
378    let face_images = provenance_by_surface(&solid, &operands, None)?;
379    Ok(OffsetShellResultRecord { solid, face_images })
380}
381
382/// Lane O row 34: the CLOSED hollow, a shell with no openings. The offset
383/// body, the whole source grown or shrunk with sharp joins, against the
384/// source: `source − body` inward and `body − source` outward, so the result
385/// is the outer shell and the inner void's shell, with the walls of the band
386/// between them. `distance` carries `offset_shell`'s sign (positive inward).
387/// `face_images` are read back from the operands by surface as in
388/// [`offset_shell_by_boolean`]: `Offset` for the body's faces, `Source` for
389/// the source's.
390///
391/// The construction checks itself: the result is exactly two shells and its
392/// volume is the difference of the operands' volumes.
393pub fn offset_shell_closed(source: &BrepSolid, distance: f64) -> Result<OffsetShellResultRecord, KernelRefusal> {
394    let body = offset_body(source, &[], distance, Body::Whole, "offset_shell_closed")?;
395    let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
396    let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
397        .map_err(|error| {
398            error.with_message(|message| format!("offset_shell: the closed hollow's difference could not be built: {message}"))
399        })?;
400    let volume = |solid: &BrepSolid| {
401        crate::solid_mass_properties(solid)
402            .map(|properties| properties.volume)
403            .or_refuse(KernelStage::Validate, "solid_mass_properties")
404    };
405    let expected = volume(kept)? - volume(removed)?;
406    let measured = volume(&solid)?;
407    if solid.shells.len() != 2 || !(expected > 0.0) || !((measured - expected).abs() <= 1e-9 * expected) {
408        return Err(KernelRefusal::internal(
409            KernelStage::Validate,
410            "offset_shell_closed_hollow",
411            format!(
412                "offset_shell: the closed hollow is not the band between its operands ({} shells, volume {measured} against {expected})",
413                solid.shells.len()
414            ),
415        ));
416    }
417    let mut operands = body
418        .solid
419        .shells
420        .iter()
421        .flat_map(|shell| &shell.faces)
422        .zip(&body.face_images)
423        .map(|(face, image)| (face, image.clone()))
424        .collect::<Vec<_>>();
425    operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
426        (face, OffsetShellFaceImageRecord { role: OffsetFaceRole::Source, source_face_id: face.id })
427    }));
428    let face_images = provenance_by_surface(&solid, &operands, None)?;
429    Ok(OffsetShellResultRecord { solid, face_images })
430}
431
432/// Whether a refusal of the direct pipeline is one of its OWN named
433/// inabilities, the ones the boolean recipe can answer differently: an
434/// `offset_shell_*` unsupported-geometry or internal refusal, or the
435/// assembler's typed non-integral-genus and degenerate-arrangement refusals
436/// that `cause_in_front` carries a shell cause through. Input refusals
437/// (distance, foreign opening, no retained face) and soundness refusals are
438/// not: the first name a caller's mistake, the second a body that must not be
439/// shipped by any route.
440pub(super) fn direct_refusal_takes_the_boolean_lane(refusal: &KernelRefusal) -> bool {
441    match &refusal.class {
442        RefusalClass::UnsupportedGeometry { what } | RefusalClass::Internal { what } => {
443            what.starts_with("offset_shell_")
444        }
445        RefusalClass::NonIntegralGenus { .. } => {
446            refusal.message.starts_with("offset_shell")
447                || (bare_genus_takes_the_boolean_lane() && refusal.message.starts_with("boolean assembly:"))
448        }
449        RefusalClass::DegenerateArrangement { .. } => refusal.message.starts_with("offset_shell"),
450        _ => false,
451    }
452}
453
454/// `BREP_OFFSET_BARE_GENUS_BOOLEAN_LANE` (default on; `=0` restores): the
455/// direct pipeline's assembly refusing a non-integral genus takes the boolean
456/// lane even with no shell cause put in front of it. Which sentence a refusal
457/// carries says nothing about whether the lane can answer:
458/// `inbox_20261017_negative_shell` only reached the lane through the note of
459/// an omission, and the omission was a misread fillet's true skin; reading
460/// that skin correctly (`BREP_OFFSET_TANGENT_EDGE_EUCLID`) left the same
461/// failed assembly with no note, and the shell was refused.
462fn bare_genus_takes_the_boolean_lane() -> bool {
463    crate::switch!("BREP_OFFSET_BARE_GENUS_BOOLEAN_LANE", true)
464}
465
466/// The cut planes: every planar face's plane parallel to a THIN PAIR, two
467/// planar faces with opposite outward normals closer than `2·amount` that face
468/// each other across void (`grow`) or across material (shrink) — the pair
469/// whose offsets cross. Deduplicated. Two sets are offered in turn: every
470/// planar face's plane parallel to a thin pair, then the thin pairs' own
471/// planes alone (when fewer), because the boolean that unions the grown
472/// pieces can fail on where one set's pieces meet and not the other's (the
473/// dumbbell's complement cut at all four y planes left sliver edges where the
474/// neck-mouth rounds of adjacent pieces cross). `None` when there is no thin
475/// pair or every set has more than eight planes.
476fn thin_pair_planes(solid: &BrepSolid, amount: f64, grow: bool) -> Result<Option<Vec<Vec<(Vec3, Vec3)>>>, KernelRefusal> {
477    let band = 1e-9 * crate::solid_scale(solid).max(1.0);
478    // Each planar face's plane, its OUTWARD normal.
479    let mut faces: Vec<(Vec3, Vec3)> = Vec::new();
480    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
481        let Some((point, normal)) = super::rim_welds::planar_surface_frame(&face.surface, band)? else {
482            continue;
483        };
484        let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
485        let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
486        let Ok(outward) = super::face_normal(face, 0.5 * (u0 + u1), 0.5 * (v0 + v1)) else {
487            continue;
488        };
489        faces.push((point, if outward.dot(normal) < 0.0 { normal.scale(-1.0) } else { normal }));
490    }
491    let mut directions: Vec<Vec3> = Vec::new();
492    let mut pairs: Vec<(usize, usize)> = Vec::new();
493    for (index, (point, normal)) in faces.iter().enumerate() {
494        for (offset, (other, other_normal)) in faces[index + 1..].iter().enumerate() {
495            if normal.dot(*other_normal) > -1.0 + 1e-12 {
496                continue;
497            }
498            // Signed gap from this face's plane to the other's, along this
499            // face's outward normal: positive across void, negative across
500            // material.
501            let gap = other.sub(*point).dot(*normal);
502            let thin = if grow { gap > band && gap < 2.0 * amount } else { gap < -band && -gap < 2.0 * amount };
503            if thin {
504                pairs.push((index, index + 1 + offset));
505                if !directions.iter().any(|axis| axis.dot(*normal).abs() >= 1.0 - 1e-12) {
506                    directions.push(*normal);
507                }
508            }
509        }
510    }
511    if directions.is_empty() {
512        return Ok(None);
513    }
514    let push = |planes: &mut Vec<(Vec3, Vec3)>, point: Vec3, normal: Vec3| {
515        let seen = planes
516            .iter()
517            .any(|(other, axis)| axis.dot(normal).abs() >= 1.0 - 1e-12 && point.sub(*other).dot(*axis).abs() <= band);
518        if !seen {
519            planes.push((point, normal));
520        }
521    };
522    let mut parallel: Vec<(Vec3, Vec3)> = Vec::new();
523    for (point, normal) in &faces {
524        if directions.iter().any(|axis| axis.dot(*normal).abs() >= 1.0 - 1e-12) {
525            push(&mut parallel, *point, *normal);
526        }
527    }
528    let mut paired: Vec<(Vec3, Vec3)> = Vec::new();
529    for (first, second) in &pairs {
530        push(&mut paired, faces[*first].0, faces[*first].1);
531        push(&mut paired, faces[*second].0, faces[*second].1);
532    }
533    let mut sets = Vec::new();
534    if parallel.len() <= 8 {
535        sets.push(parallel.clone());
536    }
537    if paired.len() <= 8 && paired.len() < parallel.len() {
538        sets.push(paired);
539    }
540    Ok((!sets.is_empty()).then_some(sets))
541}
542
543/// `solid` cut by every plane that divides a piece of it.
544fn cut_by_planes(solid: &BrepSolid, planes: &[(Vec3, Vec3)]) -> Vec<BrepSolid> {
545    let mut pieces = vec![solid.clone()];
546    for (point, normal) in planes {
547        let mut next = Vec::with_capacity(pieces.len() * 2);
548        for piece in pieces {
549            match crate::split_solid_by_plane(&piece, *point, *normal) {
550                Ok((below, above)) => {
551                    next.push(below);
552                    next.push(above);
553                }
554                Err(_) => next.push(piece),
555            }
556        }
557        pieces = next;
558    }
559    pieces
560}
561
562/// The SHRINK by `amount` through the complement: `S ⊖ D = S − ((F − S) ⊕ D)`
563/// for a frame box `F` reaching past `S` by more than `amount`, where the
564/// complement's growth is the union of its pieces' growths, the pieces cut at
565/// the planes of `S`'s planar faces.
566///
567/// Cause (a drum with a flange 1.6 thick shrunk by 1, brep-bench
568/// offset/flanged-drum-shrunk): the flange's two faces offset across each
569/// other ("a skin partly shadowed that way is not yet assembled"), and the
570/// low ridge the case reads is the two concave tori about the flange's inner
571/// rims, which the complement's pieces carry as the rounds of their own
572/// convex rims.
573fn shrunk_by_pieces(source: &BrepSolid, amount: f64) -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
574    let Some(sets) = thin_pair_planes(source, amount, false)? else {
575        return Ok(None);
576    };
577    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
578    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
579    for face in source.shells.iter().flat_map(|shell| &shell.faces) {
580        for control in face.surface.control_points.iter().flatten() {
581            let Ok(point) = control.point() else { return Ok(None) };
582            low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
583            high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
584        }
585    }
586    let margin = 3.0 * amount;
587    let corner = low.sub(Vec3::new(margin, margin, margin));
588    let size = high.sub(low).add(Vec3::new(2.0 * margin, 2.0 * margin, 2.0 * margin));
589    let frame = crate::make_box_brep(corner, size.x, size.y, size.z).or_refuse(KernelStage::Refine, "make_box_brep")?;
590    let options = BooleanOptions::default();
591    let outside = boolean_operation(&frame, source, BooleanOperation::Subtract, &options)?;
592    let mut answer = None;
593    for planes in &sets {
594        let attempt = grown_union(&outside, planes, amount).and_then(|reach| match reach {
595            Some(reach) => boolean_operation(source, &reach, BooleanOperation::Subtract, &options).map(Some),
596            None => Ok(None),
597        });
598        match attempt {
599            Ok(Some(solid)) if solid.validate().is_empty() => {
600                answer = Some(solid);
601                break;
602            }
603            Ok(_) => {}
604            Err(error) => os_debug!("shrink by pieces: {} plane(s) declined: {error}", planes.len()),
605        }
606    }
607    if answer.is_none() && shrink_by_subtraction_enabled() {
608        for planes in &sets {
609            match subtracted_one_by_one(source, &outside, planes, amount) {
610                Ok(Some(solid)) => {
611                    answer = Some(solid);
612                    break;
613                }
614                Ok(None) => {}
615                Err(error) => os_debug!("shrink by subtraction: {} plane(s) declined: {error}", planes.len()),
616            }
617        }
618    }
619    let Some(solid) = answer else {
620        return Ok(None);
621    };
622    let face_images = solid
623        .shells
624        .iter()
625        .flat_map(|shell| &shell.faces)
626        .map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
627        .collect();
628    Ok(Some(OffsetShellResultRecord { solid, face_images }))
629}
630
631/// `solid` cut at `planes`, each piece grown by `amount` with rounded joins,
632/// and the growths' union; `None` when the planes leave one piece or the
633/// union does not validate.
634fn grown_union(solid: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<BrepSolid>, KernelRefusal> {
635    let Some(grown) = grown_pieces(solid, planes, amount)? else {
636        return Ok(None);
637    };
638    let union = crate::boolean_operation_nary(&grown, BooleanOperation::Union)?;
639    Ok(union.validate().is_empty().then_some(union))
640}
641
642/// `solid` cut at `planes`, each piece grown by `amount` with rounded joins;
643/// `None` when the planes leave one piece.
644fn grown_pieces(solid: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<Vec<BrepSolid>>, KernelRefusal> {
645    let pieces = cut_by_planes(solid, planes);
646    if pieces.len() < 2 {
647        return Ok(None);
648    }
649    os_debug!("by pieces: {} plane(s) cut the body into {} piece(s)", planes.len(), pieces.len());
650    let mut grown = Vec::with_capacity(pieces.len());
651    for (index, piece) in pieces.iter().enumerate() {
652        let sharp = offset_body(piece, &[], -amount, Body::Whole, "offset_solid_piece")
653            .map_err(|error| error.with_message(|message| format!("piece {index}: {message}")))?;
654        grown.push(round_offset_joins(sharp, amount)?.solid);
655    }
656    Ok(Some(grown))
657}
658
659/// `BREP_OFFSET_SHRINK_BY_SUBTRACTION` (default on; `=0` restores): where the union of a
660/// shrink's grown complement pieces cannot be assembled, the pieces are
661/// subtracted from the body one at a time instead.
662///
663/// Cause (the dumbbell plate shrunk by 1.5, brep-bench
664/// shell/dumbbell-neck-filled): the grown pieces' concave rounds about the
665/// neck mouths cross at y = 5, and their n-ary union failed ("boolean assembly
666/// produced invalid topology (S=3 genus=2)", one-use edges ~3e-5 long near
667/// (8.882, 5, 1.5)) for every plane set. `S − (A ∪ B) = (S − A) − B`.
668fn shrink_by_subtraction_enabled() -> bool {
669    switch!("BREP_OFFSET_SHRINK_BY_SUBTRACTION", true)
670}
671
672
673fn subtracted_one_by_one(source: &BrepSolid, outside: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<BrepSolid>, KernelRefusal> {
674    let Some(grown) = grown_pieces(outside, planes, amount)? else {
675        return Ok(None);
676    };
677    let options = BooleanOptions::default();
678    let mut body = source.clone();
679    for (index, piece) in grown.iter().enumerate() {
680        body = boolean_operation(&body, piece, BooleanOperation::Subtract, &options)
681            .map_err(|error| error.with_message(|message| format!("subtracting grown piece {index}: {message}")))?;
682    }
683    Ok(body.validate().is_empty().then_some(body))
684}
685
686/// `BREP_OFFSET_REFLEX_BY_PIECES` (default on; `=0` restores): [`grown_across_reflex_edges`].
687fn reflex_by_pieces_enabled() -> bool {
688    switch!("BREP_OFFSET_REFLEX_BY_PIECES", true)
689}
690
691
692/// The ROUNDED growth of a body with CONCAVE (reflex) edges between planar
693/// faces, as the union of its pieces' growths, the pieces cut at those
694/// faces' planes; `None` (and the ordinary sharp body + rounds) where the body
695/// has none, or a piece or the union fails.
696///
697/// Cause (an L-shaped plate grown by 1, brep-bench offset/l-block-grown, read
698/// volume +0.041 and area −0.061 against the closed form): the sharp body is
699/// right, but where its reflex edge meets the top and bottom, the rounds of
700/// the two convex edges beside it must meet in a sharp concave crease, and
701/// `fillet_edges` blends that corner instead. Cut at x = 5 the plate is two
702/// boxes; each grows to a rounded box exactly, and their union carries the
703/// crease as the two rounds' own intersection.
704fn grown_across_reflex_edges(source: &BrepSolid, distance: f64) -> Option<OffsetShellResultRecord> {
705    if !reflex_by_pieces_enabled() {
706        return None;
707    }
708    let attempt = || -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
709        let band = 1e-9 * crate::solid_scale(source).max(1.0);
710        let faces = source.shells.iter().flat_map(|shell| &shell.faces).collect::<Vec<_>>();
711        let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
712        for face in &faces {
713            for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
714                uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
715            }
716        }
717        let mut planes: Vec<(Vec3, Vec3)> = Vec::new();
718        for edge in &source.edges {
719            if edge.degenerate {
720                continue;
721            }
722            let Some(pair) = uses.get(&edge.id) else { continue };
723            if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
724                continue;
725            }
726            if edge_is_convex_join(edge, pair[0], pair[1].0, 1.0 - 1e-6)? != Some(false) {
727                continue;
728            }
729            for (face, _) in pair {
730                let Some((point, normal)) = super::rim_welds::planar_surface_frame(&face.surface, band)? else {
731                    continue;
732                };
733                let seen = planes
734                    .iter()
735                    .any(|(other, axis)| axis.dot(normal).abs() >= 1.0 - 1e-12 && point.sub(*other).dot(*axis).abs() <= band);
736                if !seen {
737                    planes.push((point, normal));
738                }
739            }
740        }
741        if planes.is_empty() || planes.len() > 8 {
742            return Ok(None);
743        }
744        // One plane per reflex edge is enough, and fewer pieces meet in fewer
745        // places: try each plane alone first, then all of them.
746        let mut sets = planes.iter().map(|plane| vec![*plane]).collect::<Vec<_>>();
747        if planes.len() > 1 {
748            sets.push(planes.clone());
749        }
750        for set in &sets {
751            match grown_union(source, set, distance) {
752                Ok(Some(solid)) => {
753                    os_debug!("reflex by pieces: {} plane(s)", set.len());
754                    let face_images = solid
755                        .shells
756                        .iter()
757                        .flat_map(|shell| &shell.faces)
758                        .map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
759                        .collect();
760                    return Ok(Some(OffsetShellResultRecord { solid, face_images }));
761                }
762                Ok(None) => {}
763                Err(error) => os_debug!("reflex by pieces: {} plane(s) declined: {error}", set.len()),
764            }
765        }
766        Ok(None)
767    };
768    match attempt() {
769        Ok(result) => result,
770        Err(error) => {
771            os_debug!("reflex by pieces declined: {error}");
772            None
773        }
774    }
775}