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