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 sharp = offset_body(source, &[], -distance, Body::Whole, "offset_solid")?;
51    match corners {
52        OffsetCorners::Sharp => Ok(sharp),
53        OffsetCorners::Rounded => round_offset_joins(sharp, distance),
54    }
55}
56
57/// Roll the sharp body's joins that the ball of radius `|distance|` rounds:
58/// the convex edges of an outward offset, the concave edges of an inward one.
59/// A body with no such edge is already the parallel body and is returned as
60/// it is. Provenance: a face still on a sharp-body surface keeps that face's
61/// image; a cap (a cylinder, torus or corner ball the blend built, which lies
62/// on no offset face) is an `Offset` image with `source_face_id` 0, because it
63/// rounds a JOIN between source faces and has no single source face.
64fn round_offset_joins(
65    sharp: OffsetShellResultRecord,
66    distance: f64,
67) -> Result<OffsetShellResultRecord, KernelRefusal> {
68    let wanted_convex = distance > 0.0;
69    let midpoints = sharp_join_midpoints(&sharp.solid, wanted_convex)?;
70    if midpoints.is_empty() {
71        return Ok(sharp);
72    }
73    let solid = fillet_edges(&sharp.solid, &midpoints, None, distance.abs(), false, None)
74        .map_err(|error| {
75            error.with_message(|message| {
76                format!(
77                    "offset_solid: the rounded joins of the offset body could not be built \
78                     ({} edge(s) at radius {:.6}): {message}",
79                    midpoints.len(),
80                    distance.abs()
81                )
82            })
83        })?;
84    let operands = sharp
85        .solid
86        .shells
87        .iter()
88        .flat_map(|shell| &shell.faces)
89        .zip(&sharp.face_images)
90        .map(|(face, image)| (face, image.clone()))
91        .collect::<Vec<_>>();
92    let cap = OffsetShellFaceImageRecord {
93        role: OffsetFaceRole::Offset,
94        source_face_id: 0,
95    };
96    let face_images = provenance_by_surface(&solid, &operands, Some(&cap))?;
97    Ok(OffsetShellResultRecord { solid, face_images })
98}
99
100/// Per-face provenance of a body whose faces lie on its operands' surfaces (a
101/// boolean result, a blend's untouched faces): each result face takes the
102/// image of the FIRST operand face whose surface carries an interior point of
103/// it and whose trim contains that point's foot. Operands are matched in the
104/// order given, so a coincident pair (a held face and the opening it copies)
105/// resolves to whichever is listed first. A face on no operand surface takes
106/// `unmatched`, or refuses by name when there is none.
107fn provenance_by_surface(
108    result: &BrepSolid,
109    operands: &[(&FaceRecord, OffsetShellFaceImageRecord)],
110    unmatched: Option<&OffsetShellFaceImageRecord>,
111) -> Result<Vec<OffsetShellFaceImageRecord>, KernelRefusal> {
112    let band = 1e-6 * crate::solid_scale(result).max(1.0);
113    let mut images = Vec::new();
114    for face in result.shells.iter().flat_map(|shell| &shell.faces) {
115        let seeds = super::source_seeds(result, face.id)?;
116        let seed = seeds[0];
117        let point = face.surface.evaluate(seed.x, seed.y).or_refuse(KernelStage::Sew, "evaluate")?;
118        let mut found = None;
119        for (operand, image) in operands {
120            let Ok(foot) = project_point_to_surface(&operand.surface, point) else { continue };
121            if foot.distance > band {
122                continue;
123            }
124            let inside = super::parameter_point_in_face(operand, Vec2 { x: foot.u, y: foot.v }, 1e-8)
125                .is_ok_and(|class| class != PolygonClass::Outside);
126            if inside {
127                found = Some(image.clone());
128                break;
129            }
130        }
131        match found.or_else(|| unmatched.cloned()) {
132            Some(image) => images.push(image),
133            None => {
134                return Err(KernelRefusal::internal(
135                    KernelStage::Sew,
136                    "offset_shell_provenance_lost",
137                    format!(
138                        "offset_shell: result face {} at ({:.4}, {:.4}, {:.4}) lies on no operand face",
139                        face.id, point.x, point.y, point.z
140                    ),
141                ))
142            }
143        }
144    }
145    Ok(images)
146}
147
148/// Midpoints of the SHARP edges of `solid` whose convexity is `convex`: an
149/// edge bordering two different faces whose outward normals differ there, read
150/// at the edge midpoint. Tangent junctions (a blend face meeting its support)
151/// and face-local seams are not joins and are skipped.
152fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
153    // Cosine of the dihedral angle above which two faces read as tangent.
154    const TANGENT_COSINE: f64 = 1.0 - 1e-6;
155    let faces = solid
156        .shells
157        .iter()
158        .flat_map(|shell| &shell.faces)
159        .collect::<Vec<_>>();
160    let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
161    for face in &faces {
162        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
163            uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
164        }
165    }
166    let mut midpoints = Vec::new();
167    for edge in &solid.edges {
168        if edge.degenerate {
169            continue;
170        }
171        let Some(pair) = uses.get(&edge.id) else { continue };
172        if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
173            continue;
174        }
175        if edge_is_convex_join(edge, pair[0], pair[1].0, TANGENT_COSINE)? == Some(convex) {
176            midpoints.push(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Classify, "evaluate")?);
177        }
178    }
179    Ok(midpoints)
180}
181
182/// `Some(true)` for a convex join, `Some(false)` for a concave one, `None` for
183/// a tangent junction. With `t` the direction the first face's coedge runs
184/// along the edge and `n_a` that face's outward normal, `n_a × t` points into
185/// the first face; the join is convex when the second face's outward normal
186/// points away from there.
187fn edge_is_convex_join(
188    edge: &EdgeRecord,
189    (first, first_forward): (&FaceRecord, bool),
190    second: &FaceRecord,
191    tangent_cosine: f64,
192) -> Result<Option<bool>, KernelRefusal> {
193    let t = 0.5 * (edge.t0 + edge.t1);
194    let derivatives = edge.curve.derivatives(t, 1).or_refuse(KernelStage::Classify, "derivatives")?;
195    let point = derivatives[0];
196    let mut tangent = derivatives[1].normalized().or_refuse(KernelStage::Classify, "normalized")?;
197    if !first_forward {
198        tangent = tangent.scale(-1.0);
199    }
200    let normal_a = outward_normal_at(first, point)?;
201    let normal_b = outward_normal_at(second, point)?;
202    if normal_a.dot(normal_b) >= tangent_cosine {
203        return Ok(None);
204    }
205    Ok(Some(normal_b.dot(normal_a.cross(tangent)) < 0.0))
206}
207
208fn outward_normal_at(face: &FaceRecord, point: Vec3) -> Result<Vec3, KernelRefusal> {
209    let foot = project_point_to_surface(&face.surface, point).or_refuse(KernelStage::Classify, "project_point_to_surface")?;
210    super::face_normal(face, foot.u, foot.v)
211}
212
213// ---------------------------------------------------------------------------
214// Shell as a boolean
215// ---------------------------------------------------------------------------
216
217/// The textbook shell: the offset body with the opening faces HELD (copied at
218/// distance zero, so they cancel in the difference), then `source − body` for
219/// an inward shell and `body − source` for an outward one. The walls at the
220/// openings come out of the boolean. `distance` carries `offset_shell`'s sign
221/// (positive inward).
222///
223/// This is the lane `offset_shell` falls back to when its direct construction
224/// refuses by one of its own names (`direct_refusal_takes_the_boolean_lane`);
225/// it is public so the two can be compared on any fixture. The result's
226/// `face_images` are read back from the operands by surface
227/// (`provenance_by_surface`): `Offset` for a face of the body's skins,
228/// `Wall` for a held opening face, `Source` for a face of the source.
229pub fn offset_shell_by_boolean(
230    source: &BrepSolid,
231    opening_face_ids: &[u64],
232    distance: f64,
233) -> Result<OffsetShellResultRecord, KernelRefusal> {
234    if opening_face_ids.is_empty() {
235        return Err(KernelRefusal::input(
236            KernelStage::Collect,
237            "offset_shell_no_opening",
238            "offset_shell: at least one opening face is required",
239        ));
240    }
241    let body = offset_body(source, opening_face_ids, distance, Body::Whole, "offset_shell_by_boolean")
242        .or_else(|error| {
243            if !direct_refusal_takes_the_boolean_lane(&error) {
244                return Err(error);
245            }
246            let result = super::joint_offset::build(source, opening_face_ids, distance);
247            if let Err(reason) = &result {
248                os_debug!("joint offset refused: {reason}");
249            }
250            result.map_err(|_| error)
251        })?;
252    let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
253    let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
254        .map_err(|error| {
255            error.with_message(|message| {
256                format!("offset_shell: the boolean lane's difference could not be built: {message}")
257            })
258        })?;
259    // The body's faces first, so the ring on a held face's plane reads as
260    // that face's `Wall` rather than as the coincident source opening.
261    let mut operands = body
262        .solid
263        .shells
264        .iter()
265        .flat_map(|shell| &shell.faces)
266        .zip(&body.face_images)
267        .map(|(face, image)| (face, image.clone()))
268        .collect::<Vec<_>>();
269    operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
270        (
271            face,
272            OffsetShellFaceImageRecord {
273                role: OffsetFaceRole::Source,
274                source_face_id: face.id,
275            },
276        )
277    }));
278    let face_images = provenance_by_surface(&solid, &operands, None)?;
279    Ok(OffsetShellResultRecord { solid, face_images })
280}
281
282/// Lane O row 34: the CLOSED hollow, a shell with no openings. The offset
283/// body, the whole source grown or shrunk with sharp joins, against the
284/// source: `source − body` inward and `body − source` outward, so the result
285/// is the outer shell and the inner void's shell, with the walls of the band
286/// between them. `distance` carries `offset_shell`'s sign (positive inward).
287/// `face_images` are read back from the operands by surface as in
288/// [`offset_shell_by_boolean`]: `Offset` for the body's faces, `Source` for
289/// the source's.
290///
291/// The construction checks itself: the result is exactly two shells and its
292/// volume is the difference of the operands' volumes.
293pub fn offset_shell_closed(source: &BrepSolid, distance: f64) -> Result<OffsetShellResultRecord, KernelRefusal> {
294    let body = offset_body(source, &[], distance, Body::Whole, "offset_shell_closed")?;
295    let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
296    let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
297        .map_err(|error| {
298            error.with_message(|message| format!("offset_shell: the closed hollow's difference could not be built: {message}"))
299        })?;
300    let volume = |solid: &BrepSolid| {
301        crate::solid_mass_properties(solid)
302            .map(|properties| properties.volume)
303            .or_refuse(KernelStage::Validate, "solid_mass_properties")
304    };
305    let expected = volume(kept)? - volume(removed)?;
306    let measured = volume(&solid)?;
307    if solid.shells.len() != 2 || !(expected > 0.0) || !((measured - expected).abs() <= 1e-9 * expected) {
308        return Err(KernelRefusal::internal(
309            KernelStage::Validate,
310            "offset_shell_closed_hollow",
311            format!(
312                "offset_shell: the closed hollow is not the band between its operands ({} shells, volume {measured} against {expected})",
313                solid.shells.len()
314            ),
315        ));
316    }
317    let mut operands = body
318        .solid
319        .shells
320        .iter()
321        .flat_map(|shell| &shell.faces)
322        .zip(&body.face_images)
323        .map(|(face, image)| (face, image.clone()))
324        .collect::<Vec<_>>();
325    operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
326        (face, OffsetShellFaceImageRecord { role: OffsetFaceRole::Source, source_face_id: face.id })
327    }));
328    let face_images = provenance_by_surface(&solid, &operands, None)?;
329    Ok(OffsetShellResultRecord { solid, face_images })
330}
331
332/// Whether a refusal of the direct pipeline is one of its OWN named
333/// inabilities, the ones the boolean recipe can answer differently: an
334/// `offset_shell_*` unsupported-geometry or internal refusal, or the
335/// assembler's typed non-integral-genus and degenerate-arrangement refusals
336/// that `cause_in_front` carries a shell cause through. Input refusals
337/// (distance, foreign opening, no retained face) and soundness refusals are
338/// not: the first name a caller's mistake, the second a body that must not be
339/// shipped by any route.
340pub(super) fn direct_refusal_takes_the_boolean_lane(refusal: &KernelRefusal) -> bool {
341    match &refusal.class {
342        RefusalClass::UnsupportedGeometry { what } | RefusalClass::Internal { what } => {
343            what.starts_with("offset_shell_")
344        }
345        RefusalClass::NonIntegralGenus { .. } | RefusalClass::DegenerateArrangement { .. } => {
346            refusal.message.starts_with("offset_shell")
347        }
348        _ => false,
349    }
350}