brep_kernel/offset/offset_shell/
solid.rs1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum OffsetCorners {
36 #[default]
37 Sharp,
38 Rounded,
41}
42
43pub 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
57fn 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
100fn 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
148fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
153 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
182fn 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
213pub 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 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
282pub(super) fn direct_refusal_takes_the_boolean_lane(refusal: &KernelRefusal) -> bool {
291 match &refusal.class {
292 RefusalClass::UnsupportedGeometry { what } | RefusalClass::Internal { what } => {
293 what.starts_with("offset_shell_")
294 }
295 RefusalClass::NonIntegralGenus { .. } | RefusalClass::DegenerateArrangement { .. } => {
296 refusal.message.starts_with("offset_shell")
297 }
298 _ => false,
299 }
300}