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