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