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