1use 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 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
72fn grow_by_pieces_enabled() -> bool {
74 switch!("BREP_OFFSET_GROW_BY_PIECES", true)
75}
76
77
78fn 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
143fn 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
186fn 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
234fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
239 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
268fn 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
299pub 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 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
368pub 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
418pub(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
438fn 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 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 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
515fn 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
534fn 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
603fn 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
614fn 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
631fn 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
658fn reflex_by_pieces_enabled() -> bool {
660 switch!("BREP_OFFSET_REFLEX_BY_PIECES", true)
661}
662
663
664fn 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 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}