1use super::*;
2
3fn coheres_face_normals(solid: &mut BrepSolid) -> Result<(), String> {
14 for face in solid.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
15 face.same_sense = parameter_space_area(face)? > 0.0;
16 }
17 for shell_index in 0..solid.shells.len() {
22 let single = BrepSolid {
23 id: solid.id,
24 vertices: solid.vertices.clone(),
25 edges: solid.edges.clone(),
26 shells: vec![solid.shells[shell_index].clone()],
27 genus: 0,
28 };
29 if crate::solid_signed_volume(&single)? < 0.0 {
30 flip_shell_faces(&mut solid.shells[shell_index])?;
31 }
32 }
33 Ok(())
34}
35
36fn split_self_touching_source(source: &BrepSolid) -> Result<Option<BrepSolid>, String> {
60 if std::env::var("BREP_SELF_TOUCH_SPLIT").as_deref() == Ok("0") {
61 return Ok(None);
62 }
63 let scale = crate::solid_scale(source);
67 let tolerance = 1e-7f64.max(scale * 1e-9);
68 let edge_splits = crate::imprint::self_touch_edge_splits(source, SOURCE_OPERAND, tolerance, scale)?;
69 if edge_splits.is_empty() {
70 return Ok(None);
71 }
72 os_debug!(
73 "self-touch: source split on {} edge(s): {:?}",
74 edge_splits.len(),
75 edge_splits
76 .iter()
77 .map(|split| split.edge_id)
78 .collect::<Vec<_>>(),
79 );
80 let imprint = ImprintResultRecord {
81 edge_splits,
82 ..empty_imprint()
83 };
84 Ok(Some(apply_edge_splits(source, SOURCE_OPERAND, &imprint)?))
85}
86
87pub fn offset_shell(
88 source: &BrepSolid,
89 opening_face_ids: &[u64],
90 distance: f64,
91) -> Result<OffsetShellResultRecord, String> {
92 let prepared = split_self_touching_source(source)?;
96 let source = prepared.as_ref().unwrap_or(source);
97 let mut extension_fired = false;
105 let mut reflex_rebuilds = 0usize;
106 match offset_shell_impl(
107 source,
108 opening_face_ids,
109 distance,
110 true,
111 &mut extension_fired,
112 &mut reflex_rebuilds,
113 ) {
114 Ok(result) => {
115 os_debug!(
121 "offset_shell result: FIRST attempt (extension {}, reflex-rebuilt {})",
122 if extension_fired { "fired" } else { "idle" },
123 reflex_rebuilds,
124 );
125 Ok(result)
126 }
127 Err(error) if extension_fired => {
128 os_debug!("extended pipeline failed ({error}); retrying without carrier extension");
129 let mut unused = false;
130 let mut retry_reflex = 0usize;
131 let retried = offset_shell_impl(
132 source,
133 opening_face_ids,
134 distance,
135 false,
136 &mut unused,
137 &mut retry_reflex,
138 );
139 os_debug!(
140 "offset_shell result: RETRY without extension ({}, reflex-rebuilt {retry_reflex})",
141 if retried.is_ok() { "ok" } else { "err" },
142 );
143 retried
144 }
145 Err(error) => Err(error),
146 }
147}
148
149fn offset_shell_impl(
150 source: &BrepSolid,
151 opening_face_ids: &[u64],
152 distance: f64,
153 extend_oblique_carriers: bool,
154 extension_fired: &mut bool,
155 reflex_rebuilds: &mut usize,
156) -> Result<OffsetShellResultRecord, String> {
157 if !distance.is_finite() || distance.abs() <= 1e-7 {
158 return Err("offset_shell: distance must be finite and non-zero".into());
159 }
160 if opening_face_ids.is_empty() {
161 return Err("offset_shell: at least one opening face is required".into());
162 }
163 let source_faces = source
164 .shells
165 .iter()
166 .flat_map(|shell| &shell.faces)
167 .collect::<Vec<_>>();
168 let opening_set = opening_face_ids.iter().copied().collect::<HashSet<_>>();
169 if opening_set
170 .iter()
171 .any(|id| !source_faces.iter().any(|face| face.id == *id))
172 {
173 return Err("offset_shell: opening face does not belong to source".into());
174 }
175 let retained = source_faces
176 .iter()
177 .filter(|face| !opening_set.contains(&face.id))
178 .map(|face| face.id)
179 .collect::<Vec<_>>();
180 if retained.is_empty() {
181 return Err("offset_shell: removing every face cannot produce a shell".into());
182 }
183 let mut carriers = Vec::new();
184 for face_id in &retained {
185 let face = source_faces.iter().find(|face| face.id == *face_id).unwrap();
186 if offset_support_collapsed(face, distance)? {
187 os_debug!("omitting collapsed offset support for source face {face_id}");
188 continue;
189 }
190 let miter = source_faces
200 .iter()
201 .find(|face| face.id == *face_id)
202 .map(|face| face_reflex_miter_tan(source, face))
203 .transpose()?
204 .flatten();
205 let reflex_extension = miter.map(|worst_tan| {
212 (distance.abs() * worst_tan * 1.5)
213 .max(distance.abs())
214 .min(distance.abs() * 12.0)
215 });
216 if let Some(extension) = reflex_extension {
217 os_debug!("carrier for src face {face_id}: reflex-extended by {extension:.4}");
218 }
219 let extension = if distance < 0.0 {
226 outward_carrier_extension(source, face, &source_faces, &opening_set, distance.abs())?
227 } else {
228 crate::CarrierExtension::uniform(reflex_extension.unwrap_or(0.0))
229 };
230 if distance < 0.0 {
231 os_debug!(
232 "carrier for src face {face_id}: outward extension u=({:.3},{:.3}) v=({:.3},{:.3})",
233 extension.u_min,
234 extension.u_max,
235 extension.v_min,
236 extension.v_max,
237 );
238 }
239 let mut carrier = carrier_solid_sided(source, *face_id, distance, &extension)?;
240 if (distance < 0.0 || reflex_extension.is_some()) && face.surface.is_affine()? {
241 drop_wall_interior_loops(&mut carrier)?;
263 }
264 carriers.push(Carrier {
265 solid: carrier,
266 source_face_id: *face_id,
267 kind: OffsetFaceRole::Offset,
268 });
269 }
270 let mut apex_cap_sources = HashSet::default();
279 let mut apex_cap_pairs: Vec<(usize, usize)> = Vec::new();
280 {
281 let retained_count = carriers.len();
282 for index in 0..retained_count {
283 let source_face = source_by_id_lookup(&source_faces, carriers[index].source_face_id);
284 let Some(source_face) = source_face else {
285 continue;
286 };
287 let carrier_surface = carriers[index].solid.shells[0].faces[0].surface.clone();
288 for coedge in source_face
289 .loops
290 .iter()
291 .flat_map(|loop_record| &loop_record.coedges)
292 {
293 let Some(edge) = source
294 .edges
295 .iter()
296 .find(|edge| edge.id == coedge.edge_id)
297 else {
298 continue;
299 };
300 if !edge.degenerate {
301 continue;
302 }
303 let [p0, p1] = coedge.pcurve.domain()?;
305 let mut ring = Vec::new();
306 for sample in 0..=8 {
307 let uv = coedge
308 .pcurve
309 .evaluate(p0 + (p1 - p0) * sample as f64 / 8.0)?;
310 ring.push(carrier_surface.evaluate(uv.x, uv.y)?);
311 }
312 let spread = ring
313 .iter()
314 .map(|point| point.sub(ring[0]).length())
315 .fold(0.0f64, f64::max);
316 if spread <= distance.abs() * 1e-2 {
317 continue;
318 }
319 let apex = source
320 .vertices
321 .iter()
322 .find(|vertex| vertex.id == edge.start_vertex_id)
323 .map(|vertex| vertex.point)
324 .ok_or_else(|| "offset_shell: apex vertex missing".to_string())?;
325 let centroid = ring
326 .iter()
327 .fold(Vec3::default(), |sum, point| sum.add(*point))
328 .scale(1.0 / ring.len() as f64);
329 let axis = apex.sub(centroid);
330 let axis = if axis.length() > 1e-9 {
331 axis.normalized()?
332 } else {
333 Vec3::new(0.0, 0.0, 1.0)
334 };
335 os_debug!(
336 "apex-cap carrier for src face {} at ({:.3},{:.3},{:.3}) r={:.3}",
337 carriers[index].source_face_id,
338 apex.x,
339 apex.y,
340 apex.z,
341 distance.abs(),
342 );
343 apex_cap_sources.insert(index);
344 apex_cap_sources.insert(carriers.len());
345 apex_cap_pairs.push((index, carriers.len()));
346 carriers.push(Carrier {
347 solid: crate::make_sphere_brep(apex, distance.abs(), axis)?,
348 source_face_id: carriers[index].source_face_id,
349 kind: OffsetFaceRole::Offset,
350 });
351 }
352 }
353 }
354 for face_id in opening_face_ids {
355 let source_face = source_faces
356 .iter()
357 .find(|face| face.id == *face_id)
358 .unwrap();
359 carriers.push(Carrier {
360 solid: if distance < 0.0 && source_face.surface.is_affine()? {
361 let pad = outward_wall_pad(
362 source,
363 source_face,
364 &source_faces,
365 &opening_set,
366 distance.abs(),
367 )?;
368 os_debug!("wall for opening face {face_id}: outward pad {pad:.4}");
369 let mut wall = carrier_solid(source, *face_id, 0.0, pad)?;
370 drop_wall_interior_loops(&mut wall)?;
385 wall
386 } else {
387 standalone_face(source, *face_id)?
388 },
389 source_face_id: *face_id,
390 kind: OffsetFaceRole::Wall,
391 });
392 }
393 if carriers.len() >= SOURCE_OPERAND as usize {
394 return Err("offset_shell: too many carrier faces for current ABI".into());
395 }
396 let scale = crate::solid_scale(source);
404 crate::report_scale_migration("offset_shell", scale, || {
405 source
406 .vertices
407 .iter()
408 .map(|vertex| vertex.point.length())
409 .fold(1.0, f64::max)
410 });
411 let smooth = synchronize_smooth_offset_boundaries(&mut carriers, &source_faces, scale)?;
412 let reflex_rebuilt = rebuild_reflex_rim_carriers(
416 &mut carriers,
417 source,
418 &source_faces,
419 &smooth.pairs,
420 scale,
421 distance,
422 )?;
423 *reflex_rebuilds = reflex_rebuilt.len();
424 if extend_oblique_carriers {
425 let oblique_extended = extend_offset_carriers_past_open_hole_rims(
426 &mut carriers,
427 source,
428 &source_faces,
429 &opening_set,
430 &smooth.pairs,
431 scale,
432 )?;
433 let mut already_extended = oblique_extended.clone();
434 already_extended.extend(reflex_rebuilt.iter().copied());
435 let fallshort_extended = extend_fallshort_curved_carriers(
436 &mut carriers,
437 source,
438 &source_faces,
439 &opening_set,
440 &smooth.pairs,
441 &already_extended,
442 scale,
443 distance,
444 )?;
445 *extension_fired = !oblique_extended.is_empty() || fallshort_extended > 0;
446 os_debug!(
447 "extended {} oblique + {fallshort_extended} fall-short carriers past their opening planes",
448 oblique_extended.len(),
449 );
450 }
451 let tolerance = 1e-7f64.max(scale * 1e-9);
452 let pair_tolerance = (scale * 1e-8).max(2e-6);
453 let reach = distance.abs() * 4.0 + pair_tolerance;
454 let carrier_samples = carriers
455 .iter()
456 .map(|carrier| carrier_extent_points(&carrier.solid))
457 .collect::<Result<Vec<_>, _>>()?;
458 let carrier_bounds = carrier_samples
459 .iter()
460 .map(|samples| Bounds::from_points(samples))
461 .collect::<Option<Vec<_>>>()
462 .ok_or_else(|| "offset_shell: carrier has no boundary samples".to_string())?;
463 let source_by_id = source_faces
464 .iter()
465 .map(|face| (face.id, *face))
466 .collect::<HashMap<_, _>>();
467 let source_samples = source_faces
468 .iter()
469 .map(|face| {
470 let standalone = standalone_face(source, face.id)?;
471 Ok((face.id, edge_sample_points(&standalone)?))
472 })
473 .collect::<Result<HashMap<_, _>, String>>()?;
474 if debug_enabled() {
475 for (index, carrier) in carriers.iter().enumerate() {
476 let face = &carrier.solid.shells[0].faces[0];
477 let samples = &carrier_samples[index];
478 let bounds =
479 samples
480 .iter()
481 .fold((Vec3::default(), Vec3::default()), |(low, high), point| {
482 (
483 Vec3 {
484 x: low.x.min(point.x),
485 y: low.y.min(point.y),
486 z: low.z.min(point.z),
487 },
488 Vec3 {
489 x: high.x.max(point.x),
490 y: high.y.max(point.y),
491 z: high.z.max(point.z),
492 },
493 )
494 });
495 let surface = &face.surface;
496 let rational = surface
497 .control_points
498 .iter()
499 .flatten()
500 .any(|point| (point.w - 1.0).abs() > 1e-12);
501 os_debug!(
502 "carrier[{index}] kind={:?} source_face={} loops={} deg=({},{}) net={}x{} rational={rational} bounds=({:.3},{:.3},{:.3})..({:.3},{:.3},{:.3})",
503 carrier.kind, carrier.source_face_id, face.loops.len(),
504 surface.degree_u, surface.degree_v,
505 surface.control_points.len(),
506 surface.control_points.first().map(|row| row.len()).unwrap_or(0),
507 bounds.0.x, bounds.0.y, bounds.0.z, bounds.1.x, bounds.1.y, bounds.1.z,
508 );
509 }
510 for (index, carrier) in carriers.iter().enumerate() {
511 if !matches!(carrier.kind, OffsetFaceRole::Offset) {
512 continue;
513 }
514 let source_face = source_by_id[&carrier.source_face_id];
515 let carrier_surface = &carrier.solid.shells[0].faces[0].surface;
516 let (ku, kv) = (
517 crate::KnotVector::new(
518 source_face.surface.knots_u.clone(),
519 source_face.surface.degree_u,
520 ),
521 crate::KnotVector::new(
522 source_face.surface.knots_v.clone(),
523 source_face.surface.degree_v,
524 ),
525 );
526 if let (Ok(ku), Ok(kv)) = (ku, kv) {
527 let [u0, u1] = ku.domain();
528 let [v0, v1] = kv.domain();
529 let mut worst = 0.0f64;
530 let mut worst_at = (0.0f64, 0.0f64);
531 for iu in 0..=24 {
532 for iv in 0..=24 {
533 let u = u0 + (u1 - u0) * iu as f64 / 24.0;
534 let v = v0 + (v1 - v0) * iv as f64 / 24.0;
535 let expected = face_offsets(source_face)
536 .at(u, v, -distance)
537 .map(|sample| sample.point);
538 let actual = carrier_surface.evaluate(u, v);
539 if let (Ok(expected), Ok(actual)) = (expected, actual) {
540 let error = expected.sub(actual).length();
541 if error > worst {
542 worst = error;
543 worst_at = (u, v);
544 }
545 }
546 }
547 }
548 let mut normal_swing = 0.0f64;
549 let mut previous: Option<Vec3> = None;
550 for iu in 0..=48 {
551 let u = u0 + (u1 - u0) * iu as f64 / 48.0;
552 let v = (v0 + v1) / 2.0;
553 if let Ok(normal) = face_normal(source_face, u, v) {
554 if let Some(previous) = previous {
555 normal_swing = normal_swing.max(previous.sub(normal).length());
556 }
557 previous = Some(normal);
558 }
559 }
560 let weights = source_face
561 .surface
562 .control_points
563 .iter()
564 .flatten()
565 .map(|point| point.w)
566 .fold((f64::MAX, f64::MIN), |(low, high), w| {
567 (low.min(w), high.max(w))
568 });
569 os_debug!(
570 "carrier[{index}] offset fit error max={worst:.6} at=({:.4},{:.4}) domain_u=({u0:.4},{u1:.4}) normal_step_max={normal_swing:.4} src_net={}x{} src_deg=({},{}) src_w=({:.4},{:.4}) knots_u={:?}",
571 worst_at.0, worst_at.1,
572 source_face.surface.control_points.len(),
573 source_face.surface.control_points.first().map(|row| row.len()).unwrap_or(0),
574 source_face.surface.degree_u, source_face.surface.degree_v,
575 weights.0, weights.1,
576 &source_face.surface.knots_u,
577 );
578 }
579 }
580 os_debug!("smooth pairs: {:?}", smooth.pairs);
581 }
582 let mut imprint = empty_imprint();
583 let mut next_piece_id = 1;
584 let mut next_vertex_id = 1;
585 for first in 0..carriers.len() {
586 for second in first + 1..carriers.len() {
587 let first_source = source_by_id[&carriers[first].source_face_id];
588 let second_source = source_by_id[&carriers[second].source_face_id];
589 if matches!(carriers[first].kind, OffsetFaceRole::Wall)
590 && matches!(carriers[second].kind, OffsetFaceRole::Wall)
591 {
592 if distance > 0.0 || !source_faces_adjacent(first_source, second_source) {
604 continue;
605 }
606 }
607 if smooth.pairs.contains(&(first, second)) {
608 os_debug!("pair ({first},{second}) skipped: smooth");
609 continue;
610 }
611 if !carrier_bounds[first].intersects(carrier_bounds[second], pair_tolerance) {
612 os_debug!("pair ({first},{second}) skipped: bounds");
613 continue;
614 }
615 if !source_faces_adjacent(first_source, second_source)
616 && sample_separation(
617 &source_samples[&first_source.id],
618 &source_samples[&second_source.id],
619 ) > reach
620 {
621 os_debug!("pair ({first},{second}) skipped: separation");
622 continue;
623 }
624 let pair = match build_imprints(
625 &carriers[first].solid,
626 &carriers[second].solid,
627 &ImprintOptions {
628 tolerance,
629 maximum_fit_points: 96,
630 local_fit: true,
631 fit_chunk_points: None,
632 maximum_ssi_step: Some((scale * 0.0005).max(tolerance * 100.0)),
638 },
639 ) {
640 Ok(pair) => pair,
641 Err(error) => {
649 os_debug!("pair ({first},{second}) imprint failed: {error}");
650 continue;
651 }
652 };
653 let pieces_before = imprint.pieces.len();
654 merge_pair_imprint(
655 &mut imprint,
656 pair,
657 first as u8,
658 second as u8,
659 &mut next_piece_id,
660 &mut next_vertex_id,
661 tolerance,
662 scale,
663 );
664 os_debug!(
665 "pair ({first},{second}) src=({},{}) pieces+={}",
666 carriers[first].source_face_id,
667 carriers[second].source_face_id,
668 imprint.pieces.len() - pieces_before,
669 );
670 }
671 }
672 if distance < 0.0 {
673 for index in 0..carriers.len() {
684 if !matches!(carriers[index].kind, OffsetFaceRole::Wall) {
685 continue;
686 }
687 let opening = source_by_id[&carriers[index].source_face_id];
688 for retained_id in &retained {
689 let retained_face = source_by_id[retained_id];
690 if !source_faces_adjacent(opening, retained_face) {
691 continue;
692 }
693 let neighbor = standalone_face(source, *retained_id)?;
694 let pair = match build_imprints(
695 &carriers[index].solid,
696 &neighbor,
697 &ImprintOptions {
698 tolerance,
699 maximum_fit_points: 96,
700 local_fit: true,
701 fit_chunk_points: None,
702 maximum_ssi_step: Some((scale * 0.0005).max(tolerance * 100.0)),
703 },
704 ) {
705 Ok(pair) => pair,
706 Err(error) => {
707 os_debug!(
708 "wall×source pair ({index},{retained_id}) imprint failed: {error}"
709 );
710 continue;
711 }
712 };
713 let pieces_before = imprint.pieces.len();
714 merge_pair_imprint(
715 &mut imprint,
716 pair,
717 index as u8,
718 SOURCE_OPERAND,
719 &mut next_piece_id,
720 &mut next_vertex_id,
721 tolerance,
722 scale,
723 );
724 os_debug!(
725 "wall×source pair ({index},{retained_id}) pieces+={}",
726 imprint.pieces.len() - pieces_before,
727 );
728 }
729 }
730 }
731 for (cone_index, cap_index) in &apex_cap_pairs {
738 let cap_face_id = carriers[*cap_index].solid.shells[0].faces[0].id;
739 let piece_ids = imprint
740 .by_face
741 .iter()
742 .find(|entry| entry.operand == *cap_index as u8 && entry.face_id == cap_face_id)
743 .map(|entry| entry.piece_ids.clone())
744 .unwrap_or_default();
745 let coincidence_band = 2e-5f64.max(scale * 1e-7);
746 for piece_id in piece_ids {
747 let Some((curve, t0, t1)) = imprint
748 .pieces
749 .iter()
750 .find(|piece| piece.id == piece_id)
751 .map(|piece| (piece.curve.clone(), piece.t0, piece.t1))
752 else {
753 continue;
754 };
755 for endpoint in [curve.evaluate(t0)?, curve.evaluate(t1)?] {
756 for edge in &carriers[*cone_index].solid.edges {
757 if edge.degenerate {
758 continue;
759 }
760 let projection = crate::project_point_to_curve(&edge.curve, endpoint)?;
761 if projection.distance > coincidence_band
762 || projection.u < edge.t0 + 1e-9
763 || projection.u > edge.t1 - 1e-9
764 {
765 continue;
766 }
767 let parameter = projection.u;
768 if let Some(existing) = imprint.edge_splits.iter_mut().find(|split| {
769 split.operand == *cone_index as u8 && split.edge_id == edge.id
770 }) {
771 if !existing
772 .parameters
773 .iter()
774 .any(|value| (*value - parameter).abs() <= 1e-8)
775 {
776 existing.parameters.push(parameter);
777 }
778 } else {
779 imprint.edge_splits.push(EdgeSplitRecord {
780 operand: *cone_index as u8,
781 edge_id: edge.id,
782 parameters: vec![parameter],
783 });
784 }
785 os_debug!(
786 "apex-cap ring split: cone carrier {cone_index} edge {} at t={parameter:.6}",
787 edge.id,
788 );
789 }
790 }
791 }
792 }
793 if debug_enabled() {
794 for piece in &imprint.pieces {
795 let start = piece.curve.evaluate(piece.t0);
796 let mid = piece.curve.evaluate((piece.t0 + piece.t1) * 0.5);
797 let end = piece.curve.evaluate(piece.t1);
798 if let (Ok(start), Ok(mid), Ok(end)) = (start, mid, end) {
799 os_debug!(
800 "piece[{}] ({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})",
801 piece.id,
802 start.x,
803 start.y,
804 start.z,
805 mid.x,
806 mid.y,
807 mid.z,
808 end.x,
809 end.y,
810 end.z,
811 );
812 }
813 }
814 }
815 synchronize_smooth_edge_splits(&mut imprint, &smooth.edge_pairs);
816 let boundary_coincidence_tolerance = 2e-5f64.max(scale * 1e-7);
821 let piece_geometry = imprint
822 .pieces
823 .iter()
824 .map(|piece| {
825 (
826 piece.id,
827 FragmentEdgeGeometry {
828 curve: piece.curve.clone(),
829 t0: piece.t0,
830 t1: piece.t1,
831 },
832 )
833 })
834 .collect::<HashMap<_, _>>();
835 for (index, carrier) in carriers.iter().enumerate() {
836 if !matches!(carrier.kind, OffsetFaceRole::Offset) {
837 continue;
838 }
839 let boundaries = carrier
840 .solid
841 .edges
842 .iter()
843 .map(|edge| FragmentEdgeGeometry {
844 curve: edge.curve.clone(),
845 t0: edge.t0,
846 t1: edge.t1,
847 })
848 .collect::<Vec<_>>();
849 if let Some(by_face) = imprint
850 .by_face
851 .iter_mut()
852 .find(|entry| entry.operand == index as u8)
853 {
854 let before = by_face.piece_ids.clone();
855 by_face.piece_ids.retain(|piece_id| {
856 let piece = &piece_geometry[piece_id];
857 !boundaries.iter().any(|boundary| {
858 fragment_edge_lies_on(piece, boundary, boundary_coincidence_tolerance)
859 .unwrap_or(false)
860 })
861 });
862 if debug_enabled() && before.len() != by_face.piece_ids.len() {
863 os_debug!(
864 "carrier[{index}] dropped boundary-coincident pieces: {:?} -> {:?}",
865 before,
866 by_face.piece_ids,
867 );
868 }
869 }
870 }
871
872 if debug_enabled() {
873 for entry in &imprint.by_face {
874 os_debug!(
875 "by_face operand={} face={} pieces={:?}",
876 entry.operand,
877 entry.face_id,
878 entry.piece_ids,
879 );
880 }
881 }
882 let mut split_carriers = Vec::new();
883 let mut fragments_by_carrier = Vec::new();
884 for (index, carrier) in carriers.iter().enumerate() {
885 let split = if matches!(carrier.kind, OffsetFaceRole::Offset)
892 && !apex_cap_sources.contains(&index)
893 {
894 carrier.solid.clone()
895 } else {
896 apply_edge_splits(&carrier.solid, index as u8, &imprint)?
897 };
898 let fragments = fragment_solid(&split, index as u8, &imprint)?;
899 os_debug!(
900 "carrier[{index}] kind={:?} src={} fragments={}",
901 carrier.kind,
902 carrier.source_face_id,
903 fragments.len(),
904 );
905 if debug_enabled() {
906 for (fragment_index, fragment) in fragments.iter().enumerate() {
907 let mut boundary_count = 0usize;
908 let mut imprint_pieces = Vec::new();
909 let mut derived_count = 0usize;
910 for coedge in fragment
911 .loops
912 .iter()
913 .flat_map(|loop_record| &loop_record.coedges)
914 {
915 match &coedge.source {
916 FragmentEdgeSource::Boundary { .. }
917 | FragmentEdgeSource::SharedBoundary { .. } => boundary_count += 1,
918 FragmentEdgeSource::Imprint { piece_id } => imprint_pieces.push(*piece_id),
919 FragmentEdgeSource::Derived { .. } => derived_count += 1,
920 }
921 }
922 os_debug!(
923 " frag[{index}.{fragment_index}] test=({:.3},{:.3},{:.3}) uv=({:.3},{:.3}) boundary={boundary_count} derived={derived_count} pieces={imprint_pieces:?}",
924 fragment.test_point.x, fragment.test_point.y, fragment.test_point.z,
925 fragment.test_uv.x, fragment.test_uv.y,
926 );
927 }
928 }
929 split_carriers.push(split);
930 fragments_by_carrier.push(fragments);
931 }
932 let mut assembly_sources = [(SOURCE_OPERAND, source)]
933 .into_iter()
934 .collect::<HashMap<_, _>>();
935 for (index, carrier) in split_carriers.iter().enumerate() {
936 assembly_sources.insert(index as u8, carrier);
937 }
938
939 let source_edge_by_id = source
940 .edges
941 .iter()
942 .map(|edge| (edge.id, edge))
943 .collect::<HashMap<_, _>>();
944 let retained_faces_with_edges = retained
945 .iter()
946 .map(|face_id| {
947 let face = source_by_id[face_id];
948 let edges = face
949 .loops
950 .iter()
951 .flat_map(|loop_record| &loop_record.coedges)
952 .filter_map(|coedge| source_edge_by_id.get(&coedge.edge_id).copied())
953 .collect::<Vec<_>>();
954 (face, edges)
955 })
956 .collect::<Vec<_>>();
957 let offset_skin_tolerance = 2e-3f64.max(scale * 5e-5).max(distance.abs() * 1e-3);
958 let mut opening_planes: Vec<(usize, Vec3, Vec3)> = Vec::new();
968 if distance < 0.0 {
969 for (index, carrier) in carriers.iter().enumerate() {
970 if !matches!(carrier.kind, OffsetFaceRole::Wall) {
971 continue;
972 }
973 let opening = source_by_id[&carrier.source_face_id];
974 if !opening.surface.is_affine()? {
975 continue;
976 }
977 let [u0, u1] = opening.surface.domain_u()?;
978 let [v0, v1] = opening.surface.domain_v()?;
979 let (u, v) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
980 opening_planes.push((
981 index,
982 opening.surface.evaluate(u, v)?,
983 face_normal(opening, u, v)?,
984 ));
985 }
986 }
987 let in_front_of_other_opening = |wall_index: usize, point: Vec3| -> Result<bool, String> {
988 let band = 2e-3f64.max(scale * 5e-5);
989 for (index, plane_point, outward) in &opening_planes {
990 if *index == wall_index || point.sub(*plane_point).dot(*outward) <= band {
991 continue;
992 }
993 let face = &split_carriers[*index].shells[0].faces[0];
994 let projection = project_point_to_surface(&face.surface, point)?;
995 let uv = Vec2 {
996 x: projection.u,
997 y: projection.v,
998 };
999 if parameter_point_in_face(face, uv, 1e-8)? != PolygonClass::Outside {
1000 return Ok(true);
1001 }
1002 }
1003 Ok(false)
1004 };
1005
1006 let mut chosen_offsets = Vec::new();
1007 let mut chosen_walls = Vec::new();
1008 for (index, carrier) in carriers.iter().enumerate() {
1009 let raw_seed = source_seed(source, carrier.source_face_id)?;
1010 let seed = if matches!(carrier.kind, OffsetFaceRole::Offset) {
1011 offset_seed(
1012 source_by_id[&carrier.source_face_id],
1013 raw_seed,
1014 &split_carriers[index].shells[0].faces[0].surface,
1015 distance,
1016 2e-3f64.max(scale * 5e-5),
1017 )?
1018 .unwrap_or(raw_seed)
1019 } else {
1020 raw_seed
1021 };
1022 if seed.sub(raw_seed).length() > 1e-9 {
1023 os_debug!(
1024 " seed[{index}] moved from ({:.4},{:.4}) to the offset image ({:.4},{:.4})",
1025 raw_seed.x,
1026 raw_seed.y,
1027 seed.x,
1028 seed.y
1029 );
1030 }
1031 if matches!(carrier.kind, OffsetFaceRole::Offset) {
1032 let on_skin = fragments_by_carrier[index]
1038 .iter()
1039 .map(|fragment| {
1040 point_on_offset_skin(
1041 fragment.test_point,
1042 &retained_faces_with_edges,
1043 distance,
1044 offset_skin_tolerance,
1045 )
1046 })
1047 .collect::<Result<Vec<_>, String>>()?;
1048 let skin_filter_active = on_skin.iter().any(|flag| *flag);
1049 let mut classified = Vec::new();
1050 let mut viable = Vec::new();
1051 for (fragment_index, fragment) in fragments_by_carrier[index].iter().enumerate() {
1052 let class = classify_point(fragment.test_point, source, tolerance * 10.0)?.class;
1053 let excluded_by_class = (distance > 0.0 && class == PointClass::Out)
1054 || (distance < 0.0 && class == PointClass::In);
1055 let contact = if excluded_by_class {
1056 false
1057 } else {
1058 distance > 0.0
1059 && fragment_has_sustained_source_contact(
1060 fragment,
1061 &source_faces,
1062 &assembly_sources,
1063 &imprint,
1064 2e-3f64.max(scale * 5e-5),
1065 )?
1066 };
1067 os_debug!(
1068 " select[{index}.{fragment_index}] class={class:?} contact={contact} on_skin={} seed_in={:?} uv=({:.3},{:.3})",
1069 on_skin[fragment_index],
1070 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8),
1071 fragment.test_uv.x, fragment.test_uv.y,
1072 );
1073 if excluded_by_class || (skin_filter_active && !on_skin[fragment_index]) {
1074 continue;
1075 }
1076 classified.push(fragment.clone());
1077 if contact {
1078 continue;
1079 }
1080 viable.push(fragment.clone());
1081 }
1082 os_debug!(" select[{index}] seed=({:.4},{:.4})", seed.x, seed.y);
1083 let seeded = viable
1084 .iter()
1085 .filter(|fragment| {
1086 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)
1087 .is_ok_and(|class| class != PolygonClass::Outside)
1088 })
1089 .cloned()
1090 .collect::<Vec<_>>();
1091 let seeded_classified = classified
1092 .iter()
1093 .filter(|fragment| {
1094 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)
1095 .is_ok_and(|class| class != PolygonClass::Outside)
1096 })
1097 .cloned()
1098 .collect::<Vec<_>>();
1099 let selected = if viable.is_empty() {
1100 if !skin_filter_active {
1101 None
1111 } else {
1112 let mut by_area = classified
1113 .into_iter()
1114 .map(|fragment| {
1115 let area = parameter_space_area(&fragment_as_trim(&fragment))?.abs();
1116 Ok((fragment, area))
1117 })
1118 .collect::<Result<Vec<_>, String>>()?;
1119 by_area.sort_by(|a, b| b.1.total_cmp(&a.1));
1120 by_area.into_iter().next().map(|entry| entry.0)
1121 }
1122 } else if !seeded_classified.is_empty() {
1123 let mut candidates = seeded_classified;
1124 candidates.sort_by(|a, b| {
1125 a.test_uv
1126 .sub(seed)
1127 .length()
1128 .total_cmp(&b.test_uv.sub(seed).length())
1129 });
1130 candidates.into_iter().next()
1131 } else if viable.len() > 2 {
1132 let mut by_area = viable
1133 .into_iter()
1134 .map(|fragment| {
1135 let area = parameter_space_area(&fragment_as_trim(&fragment))?.abs();
1136 Ok((fragment, area))
1137 })
1138 .collect::<Result<Vec<_>, String>>()?;
1139 by_area.sort_by(|a, b| b.1.total_cmp(&a.1));
1140 by_area.into_iter().next().map(|entry| entry.0)
1141 } else {
1142 let mut candidates = if seeded.is_empty() { viable } else { seeded };
1143 candidates.sort_by(|a, b| {
1144 a.test_uv
1145 .sub(seed)
1146 .length()
1147 .total_cmp(&b.test_uv.sub(seed).length())
1148 });
1149 candidates.into_iter().next()
1150 };
1151 if let Some(fragment) = selected {
1152 chosen_offsets.push(fragment);
1153 }
1154 } else {
1155 let opening = source_by_id[&carrier.source_face_id];
1159 let wall_seeds = opening_wall_seeds(
1160 opening,
1161 &split_carriers[index].shells[0].faces[0],
1162 source,
1163 &opening_set,
1164 distance,
1165 )?;
1166 let carrier_planar =
1174 surface_is_planar(&split_carriers[index].shells[0].faces[0].surface, tolerance)
1175 .unwrap_or(false);
1176 let mut selected_indices = Vec::new();
1177 for (fragment_index, fragment) in fragments_by_carrier[index].iter().enumerate() {
1178 if parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)?
1179 == PolygonClass::Outside
1180 {
1181 if in_front_of_other_opening(index, fragment.test_point)? {
1182 os_debug!(
1183 " wall[{index}.{fragment_index}] skipped: in front of another \
1184 opening at ({:.3},{:.3},{:.3})",
1185 fragment.test_point.x,
1186 fragment.test_point.y,
1187 fragment.test_point.z,
1188 );
1189 continue;
1190 }
1191 if point_on_offset_skin(
1204 fragment.test_point,
1205 &retained_faces_with_edges,
1206 distance,
1207 offset_skin_tolerance,
1208 )? && !(carrier_planar
1209 && wall_seeds.iter().any(|wall_seed| {
1210 parameter_point_in_face(&fragment_as_trim(fragment), *wall_seed, 1e-8)
1211 .is_ok_and(|class| class != PolygonClass::Outside)
1212 }))
1213 {
1214 os_debug!(
1215 " wall[{index}.{fragment_index}] skipped: void cross-section at \
1216 ({:.3},{:.3},{:.3})",
1217 fragment.test_point.x,
1218 fragment.test_point.y,
1219 fragment.test_point.z,
1220 );
1221 continue;
1222 }
1223 selected_indices.push(fragment_index);
1224 }
1225 }
1226 for wall_seed in wall_seeds {
1227 let fragments = &fragments_by_carrier[index];
1228 let nearest_containing = fragments
1229 .iter()
1230 .enumerate()
1231 .filter(|(_, fragment)| {
1232 parameter_point_in_face(&fragment_as_trim(fragment), wall_seed, 1e-8)
1233 .is_ok_and(|class| class != PolygonClass::Outside)
1234 })
1235 .min_by(|(_, first), (_, second)| {
1236 first
1237 .test_uv
1238 .sub(wall_seed)
1239 .length()
1240 .total_cmp(&second.test_uv.sub(wall_seed).length())
1241 })
1242 .map(|(fragment_index, _)| fragment_index);
1243 let nearest = if nearest_containing.is_some() {
1248 nearest_containing
1249 } else {
1250 let seed_point = split_carriers[index].shells[0].faces[0]
1251 .surface
1252 .evaluate(wall_seed.x, wall_seed.y)?;
1253 fragments
1254 .iter()
1255 .enumerate()
1256 .min_by(|(_, first), (_, second)| {
1257 first
1258 .test_point
1259 .sub(seed_point)
1260 .length()
1261 .total_cmp(&second.test_point.sub(seed_point).length())
1262 })
1263 .map(|(fragment_index, _)| fragment_index)
1264 };
1265 if let Some(fragment_index) = nearest {
1266 let candidate = &fragments_by_carrier[index][fragment_index];
1267 if in_front_of_other_opening(index, candidate.test_point)? {
1268 continue;
1269 }
1270 if (nearest_containing.is_none() || !carrier_planar)
1279 && point_on_offset_skin(
1280 candidate.test_point,
1281 &retained_faces_with_edges,
1282 distance,
1283 offset_skin_tolerance,
1284 )?
1285 {
1286 os_debug!(
1287 " wall[{index}.{fragment_index}] seed-hit skipped: void \
1288 cross-section (planar={carrier_planar}) at ({:.3},{:.3},{:.3})",
1289 candidate.test_point.x,
1290 candidate.test_point.y,
1291 candidate.test_point.z,
1292 );
1293 continue;
1294 }
1295 let existing = chosen_offsets.iter().cloned().chain(
1296 selected_indices
1297 .iter()
1298 .map(|selected| fragments_by_carrier[index][*selected].clone()),
1299 );
1300 if !selected_indices.contains(&fragment_index)
1301 && !would_overuse_existing_boundary(
1302 candidate,
1303 existing,
1304 &assembly_sources,
1305 &imprint,
1306 2e-3,
1307 )?
1308 {
1309 selected_indices.push(fragment_index);
1310 }
1311 }
1312 }
1313 for fragment_index in selected_indices.iter().copied() {
1314 chosen_walls.push(fragments_by_carrier[index][fragment_index].clone());
1315 }
1316 }
1317 }
1318 if debug_enabled() {
1319 for fragment in &chosen_offsets {
1320 os_debug!(
1321 "chosen offset: operand={} src={} test=({:.3},{:.3},{:.3})",
1322 fragment.operand,
1323 carriers[fragment.operand as usize].source_face_id,
1324 fragment.test_point.x,
1325 fragment.test_point.y,
1326 fragment.test_point.z,
1327 );
1328 for (loop_index, loop_record) in fragment.loops.iter().enumerate() {
1329 for coedge in &loop_record.coedges {
1330 let surface = &fragment.surface;
1331 let [c0, c1] = coedge.pcurve.domain().unwrap_or([0.0, 0.0]);
1332 let uv0 = coedge.pcurve.evaluate(c0);
1333 let uv1 = coedge.pcurve.evaluate(c1);
1334 if let (Ok(uv0), Ok(uv1)) = (uv0, uv1) {
1335 let p0 = surface.evaluate(uv0.x, uv0.y);
1336 let p1 = surface.evaluate(uv1.x, uv1.y);
1337 if let (Ok(p0), Ok(p1)) = (p0, p1) {
1338 let kind = match &coedge.source {
1339 FragmentEdgeSource::Boundary { edge_id, .. } => {
1340 format!("bnd:{edge_id}")
1341 }
1342 FragmentEdgeSource::SharedBoundary { edge_id, .. } => {
1343 format!("sbnd:{edge_id}")
1344 }
1345 FragmentEdgeSource::Imprint { piece_id } => {
1346 format!("imp:{piece_id}")
1347 }
1348 FragmentEdgeSource::Derived { .. } => "derived".to_string(),
1349 };
1350 os_debug!(
1351 " loop{loop_index} {kind} ({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})",
1352 p0.x, p0.y, p0.z, p1.x, p1.y, p1.z,
1353 );
1354 }
1355 }
1356 }
1357 }
1358 }
1359 for fragment in &chosen_walls {
1360 os_debug!(
1361 "chosen wall: operand={} src={} test=({:.3},{:.3},{:.3})",
1362 fragment.operand,
1363 carriers[fragment.operand as usize].source_face_id,
1364 fragment.test_point.x,
1365 fragment.test_point.y,
1366 fragment.test_point.z,
1367 );
1368 }
1369 }
1370 if chosen_offsets.is_empty() {
1371 return Err("offset_shell: carrier intersections produced no offset boundary".into());
1372 }
1373 let mut boundary_use_count = HashMap::<[i64; 9], usize>::default();
1377 for fragment in &chosen_offsets {
1378 for coedge in fragment
1379 .loops
1380 .iter()
1381 .flat_map(|loop_record| &loop_record.coedges)
1382 {
1383 let key = fragment_edge_segment_key(&coedge.source, &assembly_sources, &imprint)?;
1384 *boundary_use_count.entry(key).or_default() += 1;
1385 }
1386 }
1387 let mut manifold_walls = Vec::new();
1388 for fragment in chosen_walls {
1389 let keys = fragment
1390 .loops
1391 .iter()
1392 .flat_map(|loop_record| &loop_record.coedges)
1393 .map(|coedge| fragment_edge_segment_key(&coedge.source, &assembly_sources, &imprint))
1394 .collect::<Result<Vec<_>, _>>()?;
1395 if !keys
1396 .iter()
1397 .any(|key| boundary_use_count.get(key).copied().unwrap_or(0) >= 2)
1398 {
1399 for key in keys {
1400 *boundary_use_count.entry(key).or_default() += 1;
1401 }
1402 manifold_walls.push(fragment);
1403 }
1404 }
1405 os_debug!(
1406 "manifold walls kept: {} operands={:?}",
1407 manifold_walls.len(),
1408 manifold_walls
1409 .iter()
1410 .map(|fragment| fragment.operand)
1411 .collect::<Vec<_>>(),
1412 );
1413 let chosen_walls = manifold_walls;
1414
1415 let source_fragments = fragment_solid(source, SOURCE_OPERAND, &empty_imprint())?;
1416 let mut selected = source_fragments
1417 .into_iter()
1418 .filter(|fragment| retained.contains(&fragment.source_face_id))
1419 .collect::<Vec<_>>();
1420 let mut face_images = selected
1421 .iter()
1422 .map(|fragment| OffsetShellFaceImageRecord {
1423 role: OffsetFaceRole::Source,
1424 source_face_id: fragment.source_face_id,
1425 })
1426 .collect::<Vec<_>>();
1427 if distance < 0.0 {
1428 for fragment in &mut selected {
1429 flip_fragment(fragment)?;
1430 }
1431 }
1432 if distance > 0.0 {
1433 for fragment in &mut chosen_offsets {
1434 flip_fragment(fragment)?;
1435 }
1436 }
1437 face_images.extend(
1438 chosen_offsets
1439 .iter()
1440 .map(|fragment| OffsetShellFaceImageRecord {
1441 role: OffsetFaceRole::Offset,
1442 source_face_id: carriers[fragment.operand as usize].source_face_id,
1443 }),
1444 );
1445 face_images.extend(
1446 chosen_walls
1447 .iter()
1448 .map(|fragment| OffsetShellFaceImageRecord {
1449 role: OffsetFaceRole::Wall,
1450 source_face_id: carriers[fragment.operand as usize].source_face_id,
1451 }),
1452 );
1453 selected.extend(chosen_offsets);
1454 selected.extend(chosen_walls);
1455 let mut solid = assemble_open_fragments(
1456 selected,
1457 &assembly_sources,
1458 &imprint,
1459 2e-3f64.max(scale * 5e-5),
1460 )?;
1461 orient_open_solid_faces(&mut solid)?;
1462 let connector_images = complete_sharp_offset_connectors(
1463 &mut solid,
1464 source,
1465 &face_images,
1466 distance,
1467 2e-3f64.max(scale * 5e-5),
1468 )?;
1469 os_debug!(
1470 "sharp connector completion added {} faces",
1471 connector_images.len()
1472 );
1473 if !connector_images.is_empty() {
1474 for shell in &mut solid.shells {
1479 shell.faces.retain(|face| {
1480 !matches!(
1481 face_images
1482 .get(face.id.saturating_sub(1) as usize)
1483 .map(|image| image.role),
1484 Some(OffsetFaceRole::Wall)
1485 )
1486 });
1487 }
1488 solid.shells.retain(|shell| !shell.faces.is_empty());
1489 }
1490 face_images.extend(connector_images);
1491 orient_open_solid_faces(&mut solid)?;
1492 let welded_rims = weld_coplanar_orphan_rims(&mut solid, 2e-3f64.max(scale * 5e-5))?;
1496 os_debug!("welded {welded_rims} coplanar orphan rims into opening caps");
1497 let ruled_rims = weld_ruled_offset_rims(&mut solid, 2e-3f64.max(scale * 5e-5))?;
1500 os_debug!("welded {ruled_rims} non-coplanar offset rims into ruled bands");
1501 let pair_rims =
1504 weld_coplanar_rim_pair_annuli(&mut solid, &mut face_images, 2e-3f64.max(scale * 5e-5))?;
1505 os_debug!("welded {pair_rims} coplanar rim pairs into annulus walls");
1506 if debug_enabled() {
1507 for (shell_index, shell) in solid.shells.iter().enumerate() {
1508 for face in &shell.faces {
1509 os_debug!(
1510 "POSTWELD shell {} face {} same_sense={} loops={}",
1511 shell_index,
1512 face.id,
1513 face.same_sense,
1514 face.loops.len()
1515 );
1516 for (loop_index, loop_record) in face.loops.iter().enumerate() {
1517 let walk = loop_record
1518 .coedges
1519 .iter()
1520 .map(|coedge| {
1521 let spin = (|| -> Result<f64, String> {
1522 let [d0, d1] = coedge.pcurve.domain()?;
1523 let a = coedge.pcurve.evaluate(d0 + (d1 - d0) * 0.45)?;
1524 let b = coedge.pcurve.evaluate(d0 + (d1 - d0) * 0.55)?;
1525 let pa = face.surface.evaluate(a.x, a.y)?;
1526 let pb = face.surface.evaluate(b.x, b.y)?;
1527 Ok(pa.cross(pb).z)
1528 })()
1529 .unwrap_or(f64::NAN);
1530 format!(
1531 "{}{}(spin{:+.0})",
1532 if coedge.forward { "+" } else { "-" },
1533 coedge.edge_id,
1534 spin.signum()
1535 )
1536 })
1537 .collect::<Vec<_>>();
1538 os_debug!(" loop {} {:?}", loop_index, walk);
1539 }
1540 }
1541 }
1542 }
1543 let completion_carriers = carriers.iter().collect::<Vec<_>>();
1544 let completion_tolerance = 2e-3f64.max(scale * 5e-5);
1545 let duplicate_welds = weld_duplicate_one_use_arcs(&mut solid, completion_tolerance)?;
1549 os_debug!("welded {duplicate_welds} duplicate one-use boundary arcs");
1550 if debug_enabled() {
1551 let mut use_counts = HashMap::<u64, usize>::default();
1552 for coedge in solid
1553 .shells
1554 .iter()
1555 .flat_map(|shell| &shell.faces)
1556 .flat_map(|face| &face.loops)
1557 .flat_map(|loop_record| &loop_record.coedges)
1558 {
1559 *use_counts.entry(coedge.edge_id).or_default() += 1;
1560 }
1561 let open = use_counts.values().filter(|count| **count == 1).count();
1562 os_debug!(
1563 "assembled: faces={} open_edges={open}",
1564 solid
1565 .shells
1566 .iter()
1567 .map(|shell| shell.faces.len())
1568 .sum::<usize>()
1569 );
1570 }
1571 let completed_images = complete_opening_boundary_cycles(
1572 &mut solid,
1573 &completion_carriers,
1574 &face_images,
1575 completion_tolerance,
1576 )?;
1577 os_debug!("completion added {} faces", completed_images.len());
1578 face_images.extend(completed_images);
1579 let solid = merge_same_surface_faces_open(&solid, (1e-7f64).max(scale * 1e-9))?;
1580 let assembly_tolerance = 2e-3f64.max(scale * 5e-5);
1581 let mut solid = finalize_assembled_solid(solid, assembly_tolerance)?;
1585 if ruled_rims + pair_rims > 0 {
1586 coheres_face_normals(&mut solid)?;
1591 }
1592 let solid = solid;
1593 face_images = solid
1594 .shells
1595 .iter()
1596 .flat_map(|shell| &shell.faces)
1597 .map(|face| {
1598 face_images
1599 .get(face.id.saturating_sub(1) as usize)
1600 .cloned()
1601 .ok_or_else(|| "offset_shell: merged face lost provenance".to_string())
1602 })
1603 .collect::<Result<Vec<_>, _>>()?;
1604 let bore_rings = claim_bore_end_rings(&solid, &mut face_images)?;
1608 os_debug!("{bore_rings} bore end rings took the bore wall's provenance");
1609 let mut rim_use_counts = HashMap::<u64, usize>::default();
1616 for coedge in solid
1617 .shells
1618 .iter()
1619 .flat_map(|shell| &shell.faces)
1620 .flat_map(|face| &face.loops)
1621 .flat_map(|loop_record| &loop_record.coedges)
1622 {
1623 *rim_use_counts.entry(coedge.edge_id).or_default() += 1;
1624 }
1625 if debug_enabled() {
1626 for (shell_index, shell) in solid.shells.iter().enumerate() {
1627 for face in &shell.faces {
1628 let role = face_images
1629 .get(face.id.saturating_sub(1) as usize)
1630 .map(|image| image.role);
1631 let c = face.surface.evaluate(0.5, 0.5).unwrap_or_default();
1632 os_debug!(
1633 "DUMPFACE shell {} face {} affine={} role={:?} loops={} center=({:.3},{:.3},{:.3})",
1634 shell_index, face.id, face.surface.is_affine().unwrap_or(false), role,
1635 face.loops.len(), c.x, c.y, c.z
1636 );
1637 for (li, lp) in face.loops.iter().enumerate() {
1638 let ids = lp.coedges.iter().map(|c| c.edge_id).collect::<Vec<_>>();
1639 os_debug!(" loop {} edges {:?}", li, ids);
1640 }
1641 }
1642 }
1643 for edge in &solid.edges {
1644 let uses = rim_use_counts.get(&edge.id).copied().unwrap_or(0);
1645 let s = edge.curve.evaluate(edge.t0)?;
1646 let m = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
1647 let q = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25)?;
1648 os_debug!(
1649 "DUMPEDGE edge {} uses={} closed={} deg={} cpts={} cdeg={} knots={:?} start=({:.3},{:.3},{:.3}) q=({:.3},{:.3},{:.3}) mid=({:.3},{:.3},{:.3})",
1650 edge.id, uses, edge.start_vertex_id == edge.end_vertex_id, edge.degenerate,
1651 edge.curve.control_points.len(), edge.curve.degree, edge.curve.knots,
1652 s.x, s.y, s.z, q.x, q.y, q.z, m.x, m.y, m.z
1653 );
1654 }
1655 }
1656 for edge in &solid.edges {
1657 if rim_use_counts.get(&edge.id).copied().unwrap_or(0) != 1
1658 || edge.start_vertex_id != edge.end_vertex_id
1659 {
1660 continue;
1661 }
1662 let anchor = edge.curve.evaluate(edge.t0)?;
1663 let sweep_threshold = 1e-4f64.max(scale * 1e-6);
1664 let sweeps = [0.25, 0.5, 0.75].into_iter().any(|fraction| {
1665 edge.curve
1666 .evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction)
1667 .map(|point| point.sub(anchor).length() > sweep_threshold)
1668 .unwrap_or(false)
1669 });
1670 if sweeps {
1671 if debug_enabled() {
1672 let rim_mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
1673 os_debug!(
1674 "PROBE orphan rim edge {} anchor=({:.4},{:.4},{:.4}) mid=({:.4},{:.4},{:.4})",
1675 edge.id,
1676 anchor.x,
1677 anchor.y,
1678 anchor.z,
1679 rim_mid.x,
1680 rim_mid.y,
1681 rim_mid.z
1682 );
1683 for (shell_index, shell) in solid.shells.iter().enumerate() {
1684 for face in &shell.faces {
1685 let uses_rim = face
1686 .loops
1687 .iter()
1688 .flat_map(|l| &l.coedges)
1689 .any(|c| c.edge_id == edge.id);
1690 let affine = face.surface.is_affine().unwrap_or(false);
1691 let on = edge_on_surface(edge, &face.surface, 2e-3f64.max(scale * 5e-5))
1692 .unwrap_or(false);
1693 let proj = project_point_to_surface(&face.surface, rim_mid)?;
1694 let contains = if on {
1695 parameter_point_in_face(
1696 face,
1697 Vec2 {
1698 x: proj.u,
1699 y: proj.v,
1700 },
1701 2e-3f64.max(scale * 5e-5),
1702 )? != PolygonClass::Outside
1703 } else {
1704 false
1705 };
1706 if on || uses_rim {
1707 os_debug!(
1708 " face {} shell {} affine={} uses_rim={} on_surf={} proj_dist={:.5} contains={}",
1709 face.id, shell_index, affine, uses_rim, on, proj.distance, contains
1710 );
1711 }
1712 }
1713 }
1714 }
1715 return Err(format!(
1716 "offset_shell: unwelded rim leaves a non-watertight shell \
1717 (one-use closed edge {} near ({:.3},{:.3},{:.3})); this \
1718 curved-solid opening is not yet supported",
1719 edge.id, anchor.x, anchor.y, anchor.z
1720 ));
1721 }
1722 }
1723 Ok(OffsetShellResultRecord { solid, face_images })
1724}
1725
1726pub fn offset_shell_with_diagnostics(
1729 source: &BrepSolid,
1730 opening_face_ids: &[u64],
1731 distance: f64,
1732 tolerances: Option<KernelTolerances>,
1733) -> Result<KernelOutcome<OffsetShellResultRecord>, String> {
1734 let policy = tolerances.unwrap_or_else(|| KernelTolerances::for_solid(source, 1e-7));
1735 policy.check()?;
1736 let mut diagnostics = KernelDiagnostics::default();
1737 diagnostics.count_n(
1738 "collect.source_faces",
1739 source
1740 .shells
1741 .iter()
1742 .map(|shell| shell.faces.len() as u64)
1743 .sum(),
1744 );
1745 diagnostics.count_n("collect.opening_faces", opening_face_ids.len() as u64);
1746 diagnostics.measure_max("offset.distance", distance.abs());
1747 diagnostics.measure_max("tolerance.model", policy.model);
1748 let result = offset_shell(source, opening_face_ids, distance)?;
1749 diagnostics.count_n(
1750 "sew.output_faces",
1751 result
1752 .solid
1753 .shells
1754 .iter()
1755 .map(|shell| shell.faces.len() as u64)
1756 .sum(),
1757 );
1758 diagnostics.count_n("sew.face_images", result.face_images.len() as u64);
1759 let validation = result.solid.validate_detailed(&policy);
1760 diagnostics.measure_max("validate.max_pcurve_error", validation.max_pcurve_error);
1761 diagnostics.count_n("validate.issues", validation.issues.len() as u64);
1762 diagnostics.count_n(
1763 "validate.wire_warnings",
1764 validation.wire_warnings.len() as u64,
1765 );
1766 for warning in validation.wire_warnings {
1767 diagnostics.event(
1768 DiagnosticSeverity::Warning,
1769 KernelStage::Validate,
1770 "validate.uv_wire",
1771 warning.message,
1772 );
1773 }
1774 if !validation.issues.is_empty() {
1775 for issue in &validation.issues {
1776 diagnostics.event(
1777 DiagnosticSeverity::Error,
1778 KernelStage::Validate,
1779 "validate.brep",
1780 issue.message.clone(),
1781 );
1782 }
1783 return Err(format!(
1784 "offset_shell: invalid diagnostic result: {:?}",
1785 validation.issues
1786 ));
1787 }
1788 Ok(KernelOutcome {
1789 value: result,
1790 diagnostics,
1791 })
1792}