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