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 let grown = offset_solid_joined(source, distance, corners)?;
51 if corners == OffsetCorners::Rounded && distance > 0.0 {
52 if let Some(carved) = super::bore_dimples::carve_bore_dimples(source, &grown.solid, distance) {
53 let operands = grown
54 .solid
55 .shells
56 .iter()
57 .flat_map(|shell| &shell.faces)
58 .zip(&grown.face_images)
59 .map(|(face, image)| (face, image.clone()))
60 .collect::<Vec<_>>();
61 let cap = OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 };
62 if let Ok(face_images) = provenance_by_surface(&carved, &operands, Some(&cap)) {
63 return Ok(OffsetShellResultRecord { solid: carved, face_images });
64 }
65 }
66 }
67 Ok(grown)
68}
69
70fn offset_solid_joined(
73 source: &BrepSolid,
74 distance: f64,
75 corners: OffsetCorners,
76) -> Result<OffsetShellResultRecord, KernelRefusal> {
77 if corners == OffsetCorners::Rounded && distance > 0.0 {
78 if let Some(grown) = grown_across_reflex_edges(source, distance) {
79 return Ok(grown);
80 }
81 }
82 let sharp = match offset_body(source, &[], -distance, Body::Whole, "offset_solid") {
83 Ok(sharp) => sharp,
84 Err(error) => {
85 if corners == OffsetCorners::Rounded {
86 if let Some(grown) = grown_by_pieces(source, distance) {
87 return Ok(grown);
88 }
89 if let Some(grown) = super::revolved_parallel::revolved_parallel(source, distance) {
90 return Ok(grown);
91 }
92 }
93 return Err(error);
94 }
95 };
96 match corners {
97 OffsetCorners::Sharp => Ok(sharp),
98 OffsetCorners::Rounded => match round_offset_joins(sharp, distance) {
99 Ok(rounded) => Ok(rounded),
100 Err(error) => match crate::blend::convex_parallel::convex_polyhedron_parallel(source, distance) {
103 Ok(Some((solid, images))) if solid.validate().is_empty() => {
104 let face_images = images
105 .into_iter()
106 .map(|image| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: image.unwrap_or(0) })
107 .collect();
108 Ok(OffsetShellResultRecord { solid, face_images })
109 }
110 _ => super::revolved_parallel::revolved_parallel(source, distance).ok_or(error),
111 },
112 },
113 }
114}
115
116fn grow_by_pieces_enabled() -> bool {
118 switch!("BREP_OFFSET_GROW_BY_PIECES", true)
119}
120
121
122fn grown_by_pieces(source: &BrepSolid, distance: f64) -> Option<OffsetShellResultRecord> {
145 if !grow_by_pieces_enabled() {
146 return None;
147 }
148 match grown_by_pieces_impl(source, distance) {
149 Ok(result) => result,
150 Err(error) => {
151 os_debug!("grow by pieces declined: {error}");
152 None
153 }
154 }
155}
156
157fn grown_by_pieces_impl(source: &BrepSolid, distance: f64) -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
158 if distance < 0.0 {
159 return shrunk_by_pieces(source, -distance);
160 }
161 let Some(sets) = thin_pair_planes(source, distance, true)? else {
162 return Ok(None);
163 };
164 let mut answer = None;
165 for planes in &sets {
166 match grown_union(source, planes, distance) {
167 Ok(Some(solid)) => {
168 answer = Some(solid);
169 break;
170 }
171 Ok(None) => {}
172 Err(error) => os_debug!("grow by pieces: {} plane(s) declined: {error}", planes.len()),
173 }
174 }
175 let Some(solid) = answer else {
176 return Ok(None);
177 };
178 let face_images = solid
179 .shells
180 .iter()
181 .flat_map(|shell| &shell.faces)
182 .map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
183 .collect();
184 Ok(Some(OffsetShellResultRecord { solid, face_images }))
185}
186
187fn round_offset_joins(
195 sharp: OffsetShellResultRecord,
196 distance: f64,
197) -> Result<OffsetShellResultRecord, KernelRefusal> {
198 let wanted_convex = distance > 0.0;
199 let midpoints = sharp_join_midpoints(&sharp.solid, wanted_convex)?;
200 if midpoints.is_empty() {
201 return Ok(sharp);
202 }
203 let solid = fillet_edges(&sharp.solid, &midpoints, None, distance.abs(), false, None)
204 .map_err(|error| {
205 error.with_message(|message| {
206 format!(
207 "offset_solid: the rounded joins of the offset body could not be built \
208 ({} edge(s) at radius {:.6}): {message}",
209 midpoints.len(),
210 distance.abs()
211 )
212 })
213 })?;
214 let operands = sharp
215 .solid
216 .shells
217 .iter()
218 .flat_map(|shell| &shell.faces)
219 .zip(&sharp.face_images)
220 .map(|(face, image)| (face, image.clone()))
221 .collect::<Vec<_>>();
222 let cap = OffsetShellFaceImageRecord {
223 role: OffsetFaceRole::Offset,
224 source_face_id: 0,
225 };
226 let face_images = provenance_by_surface(&solid, &operands, Some(&cap))?;
227 Ok(OffsetShellResultRecord { solid, face_images })
228}
229
230fn provenance_by_surface(
238 result: &BrepSolid,
239 operands: &[(&FaceRecord, OffsetShellFaceImageRecord)],
240 unmatched: Option<&OffsetShellFaceImageRecord>,
241) -> Result<Vec<OffsetShellFaceImageRecord>, KernelRefusal> {
242 let band = 1e-6 * crate::solid_scale(result).max(1.0);
243 let mut images = Vec::new();
244 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
245 let seeds = super::source_seeds(result, face.id)?;
246 let seed = seeds[0];
247 let point = face.surface.evaluate(seed.x, seed.y).or_refuse(KernelStage::Sew, "evaluate")?;
248 let mut found = None;
249 for (operand, image) in operands {
250 let Ok(foot) = project_point_to_surface(&operand.surface, point) else { continue };
251 if foot.distance > band {
252 continue;
253 }
254 let inside = super::parameter_point_in_face(operand, Vec2 { x: foot.u, y: foot.v }, 1e-8)
255 .is_ok_and(|class| class != PolygonClass::Outside);
256 if inside {
257 found = Some(image.clone());
258 break;
259 }
260 }
261 match found.or_else(|| unmatched.cloned()) {
262 Some(image) => images.push(image),
263 None => {
264 return Err(KernelRefusal::internal(
265 KernelStage::Sew,
266 "offset_shell_provenance_lost",
267 format!(
268 "offset_shell: result face {} at ({:.4}, {:.4}, {:.4}) lies on no operand face",
269 face.id, point.x, point.y, point.z
270 ),
271 ))
272 }
273 }
274 }
275 Ok(images)
276}
277
278fn sharp_join_midpoints(solid: &BrepSolid, convex: bool) -> Result<Vec<Vec3>, KernelRefusal> {
283 const TANGENT_COSINE: f64 = 1.0 - 1e-6;
285 let faces = solid
286 .shells
287 .iter()
288 .flat_map(|shell| &shell.faces)
289 .collect::<Vec<_>>();
290 let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
291 for face in &faces {
292 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
293 uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
294 }
295 }
296 let mut midpoints = Vec::new();
297 for edge in &solid.edges {
298 if edge.degenerate {
299 continue;
300 }
301 let Some(pair) = uses.get(&edge.id) else { continue };
302 if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
303 continue;
304 }
305 if edge_is_convex_join(edge, pair[0], pair[1].0, TANGENT_COSINE)? == Some(convex) {
306 midpoints.push(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Classify, "evaluate")?);
307 }
308 }
309 Ok(midpoints)
310}
311
312fn edge_is_convex_join(
318 edge: &EdgeRecord,
319 (first, first_forward): (&FaceRecord, bool),
320 second: &FaceRecord,
321 tangent_cosine: f64,
322) -> Result<Option<bool>, KernelRefusal> {
323 let t = 0.5 * (edge.t0 + edge.t1);
324 let derivatives = edge.curve.derivatives(t, 1).or_refuse(KernelStage::Classify, "derivatives")?;
325 let point = derivatives[0];
326 let mut tangent = derivatives[1].normalized().or_refuse(KernelStage::Classify, "normalized")?;
327 if !first_forward {
328 tangent = tangent.scale(-1.0);
329 }
330 let normal_a = outward_normal_at(first, point)?;
331 let normal_b = outward_normal_at(second, point)?;
332 if normal_a.dot(normal_b) >= tangent_cosine {
333 return Ok(None);
334 }
335 Ok(Some(normal_b.dot(normal_a.cross(tangent)) < 0.0))
336}
337
338fn outward_normal_at(face: &FaceRecord, point: Vec3) -> Result<Vec3, KernelRefusal> {
339 let foot = project_point_to_surface(&face.surface, point).or_refuse(KernelStage::Classify, "project_point_to_surface")?;
340 super::face_normal(face, foot.u, foot.v)
341}
342
343pub fn offset_shell_by_boolean(
360 source: &BrepSolid,
361 opening_face_ids: &[u64],
362 distance: f64,
363) -> Result<OffsetShellResultRecord, KernelRefusal> {
364 if opening_face_ids.is_empty() {
365 return Err(KernelRefusal::input(
366 KernelStage::Collect,
367 "offset_shell_no_opening",
368 "offset_shell: at least one opening face is required",
369 ));
370 }
371 let body = offset_body(source, opening_face_ids, distance, Body::Whole, "offset_shell_by_boolean")
372 .or_else(|error| {
373 if !direct_refusal_takes_the_boolean_lane(&error) {
374 return Err(error);
375 }
376 let result = super::joint_offset::build(source, opening_face_ids, distance);
377 if let Err(reason) = &result {
378 os_debug!("joint offset refused: {reason}");
379 }
380 result.map_err(|_| error)
381 })?;
382 let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
383 let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
384 .map_err(|error| {
385 error.with_message(|message| {
386 format!("offset_shell: the boolean lane's difference could not be built: {message}")
387 })
388 })?;
389 let mut operands = body
392 .solid
393 .shells
394 .iter()
395 .flat_map(|shell| &shell.faces)
396 .zip(&body.face_images)
397 .map(|(face, image)| (face, image.clone()))
398 .collect::<Vec<_>>();
399 operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
400 (
401 face,
402 OffsetShellFaceImageRecord {
403 role: OffsetFaceRole::Source,
404 source_face_id: face.id,
405 },
406 )
407 }));
408 let face_images = provenance_by_surface(&solid, &operands, None)?;
409 Ok(OffsetShellResultRecord { solid, face_images })
410}
411
412pub fn offset_shell_closed(source: &BrepSolid, distance: f64) -> Result<OffsetShellResultRecord, KernelRefusal> {
424 let body = offset_body(source, &[], distance, Body::Whole, "offset_shell_closed")?;
425 let (kept, removed) = if distance > 0.0 { (source, &body.solid) } else { (&body.solid, source) };
426 let solid = boolean_operation(kept, removed, BooleanOperation::Subtract, &BooleanOptions::default())
427 .map_err(|error| {
428 error.with_message(|message| format!("offset_shell: the closed hollow's difference could not be built: {message}"))
429 })?;
430 let volume = |solid: &BrepSolid| {
431 crate::solid_mass_properties(solid)
432 .map(|properties| properties.volume)
433 .or_refuse(KernelStage::Validate, "solid_mass_properties")
434 };
435 let expected = volume(kept)? - volume(removed)?;
436 let measured = volume(&solid)?;
437 if solid.shells.len() != 2 || !(expected > 0.0) || !((measured - expected).abs() <= 1e-9 * expected) {
438 return Err(KernelRefusal::internal(
439 KernelStage::Validate,
440 "offset_shell_closed_hollow",
441 format!(
442 "offset_shell: the closed hollow is not the band between its operands ({} shells, volume {measured} against {expected})",
443 solid.shells.len()
444 ),
445 ));
446 }
447 let mut operands = body
448 .solid
449 .shells
450 .iter()
451 .flat_map(|shell| &shell.faces)
452 .zip(&body.face_images)
453 .map(|(face, image)| (face, image.clone()))
454 .collect::<Vec<_>>();
455 operands.extend(source.shells.iter().flat_map(|shell| &shell.faces).map(|face| {
456 (face, OffsetShellFaceImageRecord { role: OffsetFaceRole::Source, source_face_id: face.id })
457 }));
458 let face_images = provenance_by_surface(&solid, &operands, None)?;
459 Ok(OffsetShellResultRecord { solid, face_images })
460}
461
462pub(super) fn direct_refusal_takes_the_boolean_lane(refusal: &KernelRefusal) -> bool {
471 match &refusal.class {
472 RefusalClass::UnsupportedGeometry { what } | RefusalClass::Internal { what } => {
473 what.starts_with("offset_shell_")
474 }
475 RefusalClass::NonIntegralGenus { .. } => {
476 refusal.message.starts_with("offset_shell")
477 || (bare_genus_takes_the_boolean_lane() && refusal.message.starts_with("boolean assembly:"))
478 }
479 RefusalClass::DegenerateArrangement { .. } => refusal.message.starts_with("offset_shell"),
480 _ => false,
481 }
482}
483
484fn bare_genus_takes_the_boolean_lane() -> bool {
493 crate::switch!("BREP_OFFSET_BARE_GENUS_BOOLEAN_LANE", true)
494}
495
496fn thin_pair_planes(solid: &BrepSolid, amount: f64, grow: bool) -> Result<Option<Vec<Vec<(Vec3, Vec3)>>>, KernelRefusal> {
507 let band = 1e-9 * crate::solid_scale(solid).max(1.0);
508 let mut faces: Vec<(Vec3, Vec3)> = Vec::new();
510 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
511 let Some((point, normal)) = super::rim_welds::planar_surface_frame(&face.surface, band)? else {
512 continue;
513 };
514 let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
515 let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
516 let Ok(outward) = super::face_normal(face, 0.5 * (u0 + u1), 0.5 * (v0 + v1)) else {
517 continue;
518 };
519 faces.push((point, if outward.dot(normal) < 0.0 { normal.scale(-1.0) } else { normal }));
520 }
521 let mut directions: Vec<Vec3> = Vec::new();
522 let mut pairs: Vec<(usize, usize)> = Vec::new();
523 for (index, (point, normal)) in faces.iter().enumerate() {
524 for (offset, (other, other_normal)) in faces[index + 1..].iter().enumerate() {
525 if normal.dot(*other_normal) > -1.0 + 1e-12 {
526 continue;
527 }
528 let gap = other.sub(*point).dot(*normal);
532 let thin = if grow { gap > band && gap < 2.0 * amount } else { gap < -band && -gap < 2.0 * amount };
533 if thin {
534 pairs.push((index, index + 1 + offset));
535 if !directions.iter().any(|axis| axis.dot(*normal).abs() >= 1.0 - 1e-12) {
536 directions.push(*normal);
537 }
538 }
539 }
540 }
541 if directions.is_empty() {
542 return Ok(None);
543 }
544 let push = |planes: &mut Vec<(Vec3, Vec3)>, point: Vec3, normal: Vec3| {
545 let seen = planes
546 .iter()
547 .any(|(other, axis)| axis.dot(normal).abs() >= 1.0 - 1e-12 && point.sub(*other).dot(*axis).abs() <= band);
548 if !seen {
549 planes.push((point, normal));
550 }
551 };
552 let mut parallel: Vec<(Vec3, Vec3)> = Vec::new();
553 for (point, normal) in &faces {
554 if directions.iter().any(|axis| axis.dot(*normal).abs() >= 1.0 - 1e-12) {
555 push(&mut parallel, *point, *normal);
556 }
557 }
558 let mut paired: Vec<(Vec3, Vec3)> = Vec::new();
559 for (first, second) in &pairs {
560 push(&mut paired, faces[*first].0, faces[*first].1);
561 push(&mut paired, faces[*second].0, faces[*second].1);
562 }
563 let mut sets = Vec::new();
564 if parallel.len() <= 8 {
565 sets.push(parallel.clone());
566 }
567 if paired.len() <= 8 && paired.len() < parallel.len() {
568 sets.push(paired);
569 }
570 Ok((!sets.is_empty()).then_some(sets))
571}
572
573fn cut_by_planes(solid: &BrepSolid, planes: &[(Vec3, Vec3)]) -> Vec<BrepSolid> {
575 let mut pieces = vec![solid.clone()];
576 for (point, normal) in planes {
577 let mut next = Vec::with_capacity(pieces.len() * 2);
578 for piece in pieces {
579 match crate::split_solid_by_plane(&piece, *point, *normal) {
580 Ok((below, above)) => {
581 next.push(below);
582 next.push(above);
583 }
584 Err(_) => next.push(piece),
585 }
586 }
587 pieces = next;
588 }
589 pieces
590}
591
592fn shrunk_by_pieces(source: &BrepSolid, amount: f64) -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
604 let Some(sets) = thin_pair_planes(source, amount, false)? else {
605 return Ok(None);
606 };
607 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
608 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
609 for face in source.shells.iter().flat_map(|shell| &shell.faces) {
610 for control in face.surface.control_points.iter().flatten() {
611 let Ok(point) = control.point() else { return Ok(None) };
612 low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
613 high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
614 }
615 }
616 let margin = 3.0 * amount;
617 let corner = low.sub(Vec3::new(margin, margin, margin));
618 let size = high.sub(low).add(Vec3::new(2.0 * margin, 2.0 * margin, 2.0 * margin));
619 let frame = crate::make_box_brep(corner, size.x, size.y, size.z).or_refuse(KernelStage::Refine, "make_box_brep")?;
620 let options = BooleanOptions::default();
621 let outside = boolean_operation(&frame, source, BooleanOperation::Subtract, &options)?;
622 let mut answer = None;
623 for planes in &sets {
624 let attempt = grown_union(&outside, planes, amount).and_then(|reach| match reach {
625 Some(reach) => boolean_operation(source, &reach, BooleanOperation::Subtract, &options).map(Some),
626 None => Ok(None),
627 });
628 match attempt {
629 Ok(Some(solid)) if solid.validate().is_empty() => {
630 answer = Some(solid);
631 break;
632 }
633 Ok(_) => {}
634 Err(error) => os_debug!("shrink by pieces: {} plane(s) declined: {error}", planes.len()),
635 }
636 }
637 if answer.is_none() && shrink_by_subtraction_enabled() {
638 for planes in &sets {
639 match subtracted_one_by_one(source, &outside, planes, amount) {
640 Ok(Some(solid)) => {
641 answer = Some(solid);
642 break;
643 }
644 Ok(None) => {}
645 Err(error) => os_debug!("shrink by subtraction: {} plane(s) declined: {error}", planes.len()),
646 }
647 }
648 }
649 let Some(solid) = answer else {
650 return Ok(None);
651 };
652 let face_images = solid
653 .shells
654 .iter()
655 .flat_map(|shell| &shell.faces)
656 .map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
657 .collect();
658 Ok(Some(OffsetShellResultRecord { solid, face_images }))
659}
660
661fn grown_union(solid: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<BrepSolid>, KernelRefusal> {
665 let Some(grown) = grown_pieces(solid, planes, amount)? else {
666 return Ok(None);
667 };
668 let union = crate::boolean_operation_nary(&grown, BooleanOperation::Union)?;
669 Ok(union.validate().is_empty().then_some(union))
670}
671
672fn grown_pieces(solid: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<Vec<BrepSolid>>, KernelRefusal> {
675 let pieces = cut_by_planes(solid, planes);
676 if pieces.len() < 2 {
677 return Ok(None);
678 }
679 os_debug!("by pieces: {} plane(s) cut the body into {} piece(s)", planes.len(), pieces.len());
680 let mut grown = Vec::with_capacity(pieces.len());
681 for (index, piece) in pieces.iter().enumerate() {
682 let sharp = offset_body(piece, &[], -amount, Body::Whole, "offset_solid_piece")
683 .map_err(|error| error.with_message(|message| format!("piece {index}: {message}")))?;
684 grown.push(round_offset_joins(sharp, amount)?.solid);
685 }
686 Ok(Some(grown))
687}
688
689fn shrink_by_subtraction_enabled() -> bool {
699 switch!("BREP_OFFSET_SHRINK_BY_SUBTRACTION", true)
700}
701
702
703fn subtracted_one_by_one(source: &BrepSolid, outside: &BrepSolid, planes: &[(Vec3, Vec3)], amount: f64) -> Result<Option<BrepSolid>, KernelRefusal> {
704 let Some(grown) = grown_pieces(outside, planes, amount)? else {
705 return Ok(None);
706 };
707 let options = BooleanOptions::default();
708 let mut body = source.clone();
709 for (index, piece) in grown.iter().enumerate() {
710 body = boolean_operation(&body, piece, BooleanOperation::Subtract, &options)
711 .map_err(|error| error.with_message(|message| format!("subtracting grown piece {index}: {message}")))?;
712 }
713 Ok(body.validate().is_empty().then_some(body))
714}
715
716fn reflex_by_pieces_enabled() -> bool {
718 switch!("BREP_OFFSET_REFLEX_BY_PIECES", true)
719}
720
721
722fn grown_across_reflex_edges(source: &BrepSolid, distance: f64) -> Option<OffsetShellResultRecord> {
735 if !reflex_by_pieces_enabled() {
736 return None;
737 }
738 let attempt = || -> Result<Option<OffsetShellResultRecord>, KernelRefusal> {
739 let band = 1e-9 * crate::solid_scale(source).max(1.0);
740 let faces = source.shells.iter().flat_map(|shell| &shell.faces).collect::<Vec<_>>();
741 let mut uses = HashMap::<u64, Vec<(&FaceRecord, bool)>>::default();
742 for face in &faces {
743 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
744 uses.entry(coedge.edge_id).or_default().push((face, coedge.forward));
745 }
746 }
747 let mut planes: Vec<(Vec3, Vec3)> = Vec::new();
748 for edge in &source.edges {
749 if edge.degenerate {
750 continue;
751 }
752 let Some(pair) = uses.get(&edge.id) else { continue };
753 if pair.len() != 2 || pair[0].0.id == pair[1].0.id {
754 continue;
755 }
756 if edge_is_convex_join(edge, pair[0], pair[1].0, 1.0 - 1e-6)? != Some(false) {
757 continue;
758 }
759 for (face, _) in pair {
760 let Some((point, normal)) = super::rim_welds::planar_surface_frame(&face.surface, band)? else {
761 continue;
762 };
763 let seen = planes
764 .iter()
765 .any(|(other, axis)| axis.dot(normal).abs() >= 1.0 - 1e-12 && point.sub(*other).dot(*axis).abs() <= band);
766 if !seen {
767 planes.push((point, normal));
768 }
769 }
770 }
771 if planes.is_empty() || planes.len() > 8 {
772 return Ok(None);
773 }
774 let mut sets = planes.iter().map(|plane| vec![*plane]).collect::<Vec<_>>();
777 if planes.len() > 1 {
778 sets.push(planes.clone());
779 }
780 for set in &sets {
781 match grown_union(source, set, distance) {
782 Ok(Some(solid)) => {
783 os_debug!("reflex by pieces: {} plane(s)", set.len());
784 let face_images = solid
785 .shells
786 .iter()
787 .flat_map(|shell| &shell.faces)
788 .map(|_| OffsetShellFaceImageRecord { role: OffsetFaceRole::Offset, source_face_id: 0 })
789 .collect();
790 return Ok(Some(OffsetShellResultRecord { solid, face_images }));
791 }
792 Ok(None) => {}
793 Err(error) => os_debug!("reflex by pieces: {} plane(s) declined: {error}", set.len()),
794 }
795 }
796 Ok(None)
797 };
798 match attempt() {
799 Ok(result) => result,
800 Err(error) => {
801 os_debug!("reflex by pieces declined: {error}");
802 None
803 }
804 }
805}