brep_kernel/offset/offset_shell/
solid.rs1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum OffsetCorners {
35 #[default]
36 Sharp,
37 Rounded,
40}
41
42pub 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
56fn 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
99fn 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
147fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
152 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
181fn 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
212pub 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 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
271pub(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}