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
36pub fn offset_shell(
37 source: &BrepSolid,
38 opening_face_ids: &[u64],
39 distance: f64,
40) -> Result<OffsetShellResultRecord, String> {
41 let mut extension_fired = false;
49 let mut reflex_rebuilds = 0usize;
50 match offset_shell_impl(
51 source,
52 opening_face_ids,
53 distance,
54 true,
55 &mut extension_fired,
56 &mut reflex_rebuilds,
57 ) {
58 Ok(result) => {
59 os_debug!(
65 "offset_shell result: FIRST attempt (extension {}, reflex-rebuilt {})",
66 if extension_fired { "fired" } else { "idle" },
67 reflex_rebuilds,
68 );
69 Ok(result)
70 }
71 Err(error) if extension_fired => {
72 os_debug!("extended pipeline failed ({error}); retrying without carrier extension");
73 let mut unused = false;
74 let mut retry_reflex = 0usize;
75 let retried = offset_shell_impl(
76 source,
77 opening_face_ids,
78 distance,
79 false,
80 &mut unused,
81 &mut retry_reflex,
82 );
83 os_debug!(
84 "offset_shell result: RETRY without extension ({}, reflex-rebuilt {retry_reflex})",
85 if retried.is_ok() { "ok" } else { "err" },
86 );
87 retried
88 }
89 Err(error) => Err(error),
90 }
91}
92
93fn offset_shell_impl(
94 source: &BrepSolid,
95 opening_face_ids: &[u64],
96 distance: f64,
97 extend_oblique_carriers: bool,
98 extension_fired: &mut bool,
99 reflex_rebuilds: &mut usize,
100) -> Result<OffsetShellResultRecord, String> {
101 if !distance.is_finite() || distance.abs() <= 1e-7 {
102 return Err("offset_shell: distance must be finite and non-zero".into());
103 }
104 if opening_face_ids.is_empty() {
105 return Err("offset_shell: at least one opening face is required".into());
106 }
107 let source_faces = source
108 .shells
109 .iter()
110 .flat_map(|shell| &shell.faces)
111 .collect::<Vec<_>>();
112 let opening_set = opening_face_ids.iter().copied().collect::<HashSet<_>>();
113 if opening_set
114 .iter()
115 .any(|id| !source_faces.iter().any(|face| face.id == *id))
116 {
117 return Err("offset_shell: opening face does not belong to source".into());
118 }
119 let retained = source_faces
120 .iter()
121 .filter(|face| !opening_set.contains(&face.id))
122 .map(|face| face.id)
123 .collect::<Vec<_>>();
124 if retained.is_empty() {
125 return Err("offset_shell: removing every face cannot produce a shell".into());
126 }
127 let mut carriers = Vec::new();
128 for face_id in &retained {
129 let face = source_faces.iter().find(|face| face.id == *face_id).unwrap();
130 if offset_support_collapsed(face, distance)? {
131 os_debug!("omitting collapsed offset support for source face {face_id}");
132 continue;
133 }
134 let miter = source_faces
144 .iter()
145 .find(|face| face.id == *face_id)
146 .map(|face| face_reflex_miter_tan(source, face))
147 .transpose()?
148 .flatten();
149 let reflex_extension = miter.map(|worst_tan| {
156 (distance.abs() * worst_tan * 1.5)
157 .max(distance.abs())
158 .min(distance.abs() * 12.0)
159 });
160 if let Some(extension) = reflex_extension {
161 os_debug!("carrier for src face {face_id}: reflex-extended by {extension:.4}");
162 }
163 let extension = if distance < 0.0 {
170 outward_carrier_extension(source, face, &source_faces, &opening_set, distance.abs())?
171 } else {
172 crate::CarrierExtension::uniform(reflex_extension.unwrap_or(0.0))
173 };
174 if distance < 0.0 {
175 os_debug!(
176 "carrier for src face {face_id}: outward extension u=({:.3},{:.3}) v=({:.3},{:.3})",
177 extension.u_min,
178 extension.u_max,
179 extension.v_min,
180 extension.v_max,
181 );
182 }
183 let mut carrier = carrier_solid_sided(source, *face_id, distance, &extension)?;
184 if (distance < 0.0 || reflex_extension.is_some()) && face.surface.is_affine()? {
185 drop_wall_interior_loops(&mut carrier)?;
207 }
208 carriers.push(Carrier {
209 solid: carrier,
210 source_face_id: *face_id,
211 kind: OffsetFaceRole::Offset,
212 });
213 }
214 let mut apex_cap_sources = HashSet::default();
223 let mut apex_cap_pairs: Vec<(usize, usize)> = Vec::new();
224 {
225 let retained_count = carriers.len();
226 for index in 0..retained_count {
227 let source_face = source_by_id_lookup(&source_faces, carriers[index].source_face_id);
228 let Some(source_face) = source_face else {
229 continue;
230 };
231 let carrier_surface = carriers[index].solid.shells[0].faces[0].surface.clone();
232 for coedge in source_face
233 .loops
234 .iter()
235 .flat_map(|loop_record| &loop_record.coedges)
236 {
237 let Some(edge) = source
238 .edges
239 .iter()
240 .find(|edge| edge.id == coedge.edge_id)
241 else {
242 continue;
243 };
244 if !edge.degenerate {
245 continue;
246 }
247 let [p0, p1] = coedge.pcurve.domain()?;
249 let mut ring = Vec::new();
250 for sample in 0..=8 {
251 let uv = coedge
252 .pcurve
253 .evaluate(p0 + (p1 - p0) * sample as f64 / 8.0)?;
254 ring.push(carrier_surface.evaluate(uv.x, uv.y)?);
255 }
256 let spread = ring
257 .iter()
258 .map(|point| point.sub(ring[0]).length())
259 .fold(0.0f64, f64::max);
260 if spread <= distance.abs() * 1e-2 {
261 continue;
262 }
263 let apex = source
264 .vertices
265 .iter()
266 .find(|vertex| vertex.id == edge.start_vertex_id)
267 .map(|vertex| vertex.point)
268 .ok_or_else(|| "offset_shell: apex vertex missing".to_string())?;
269 let centroid = ring
270 .iter()
271 .fold(Vec3::default(), |sum, point| sum.add(*point))
272 .scale(1.0 / ring.len() as f64);
273 let axis = apex.sub(centroid);
274 let axis = if axis.length() > 1e-9 {
275 axis.normalized()?
276 } else {
277 Vec3::new(0.0, 0.0, 1.0)
278 };
279 os_debug!(
280 "apex-cap carrier for src face {} at ({:.3},{:.3},{:.3}) r={:.3}",
281 carriers[index].source_face_id,
282 apex.x,
283 apex.y,
284 apex.z,
285 distance.abs(),
286 );
287 apex_cap_sources.insert(index);
288 apex_cap_sources.insert(carriers.len());
289 apex_cap_pairs.push((index, carriers.len()));
290 carriers.push(Carrier {
291 solid: crate::make_sphere_brep(apex, distance.abs(), axis)?,
292 source_face_id: carriers[index].source_face_id,
293 kind: OffsetFaceRole::Offset,
294 });
295 }
296 }
297 }
298 for face_id in opening_face_ids {
299 let source_face = source_faces
300 .iter()
301 .find(|face| face.id == *face_id)
302 .unwrap();
303 carriers.push(Carrier {
304 solid: if distance < 0.0 && source_face.surface.is_affine()? {
305 let pad = outward_wall_pad(
306 source,
307 source_face,
308 &source_faces,
309 &opening_set,
310 distance.abs(),
311 )?;
312 os_debug!("wall for opening face {face_id}: outward pad {pad:.4}");
313 let mut wall = carrier_solid(source, *face_id, 0.0, pad)?;
314 drop_wall_interior_loops(&mut wall)?;
329 wall
330 } else {
331 standalone_face(source, *face_id)?
332 },
333 source_face_id: *face_id,
334 kind: OffsetFaceRole::Wall,
335 });
336 }
337 if carriers.len() >= SOURCE_OPERAND as usize {
338 return Err("offset_shell: too many carrier faces for current ABI".into());
339 }
340 let scale = crate::solid_scale(source);
348 crate::report_scale_migration("offset_shell", scale, || {
349 source
350 .vertices
351 .iter()
352 .map(|vertex| vertex.point.length())
353 .fold(1.0, f64::max)
354 });
355 let smooth = synchronize_smooth_offset_boundaries(&mut carriers, &source_faces, scale)?;
356 let reflex_rebuilt = rebuild_reflex_rim_carriers(
360 &mut carriers,
361 source,
362 &source_faces,
363 &smooth.pairs,
364 scale,
365 distance,
366 )?;
367 *reflex_rebuilds = reflex_rebuilt.len();
368 if extend_oblique_carriers {
369 let oblique_extended = extend_offset_carriers_past_open_hole_rims(
370 &mut carriers,
371 source,
372 &source_faces,
373 &opening_set,
374 &smooth.pairs,
375 scale,
376 )?;
377 let mut already_extended = oblique_extended.clone();
378 already_extended.extend(reflex_rebuilt.iter().copied());
379 let fallshort_extended = extend_fallshort_curved_carriers(
380 &mut carriers,
381 source,
382 &source_faces,
383 &opening_set,
384 &smooth.pairs,
385 &already_extended,
386 scale,
387 distance,
388 )?;
389 *extension_fired = !oblique_extended.is_empty() || fallshort_extended > 0;
390 os_debug!(
391 "extended {} oblique + {fallshort_extended} fall-short carriers past their opening planes",
392 oblique_extended.len(),
393 );
394 }
395 let tolerance = 1e-7f64.max(scale * 1e-9);
396 let pair_tolerance = (scale * 1e-8).max(2e-6);
397 let reach = distance.abs() * 4.0 + pair_tolerance;
398 let carrier_samples = carriers
399 .iter()
400 .map(|carrier| carrier_extent_points(&carrier.solid))
401 .collect::<Result<Vec<_>, _>>()?;
402 let carrier_bounds = carrier_samples
403 .iter()
404 .map(|samples| Bounds::from_points(samples))
405 .collect::<Option<Vec<_>>>()
406 .ok_or_else(|| "offset_shell: carrier has no boundary samples".to_string())?;
407 let source_by_id = source_faces
408 .iter()
409 .map(|face| (face.id, *face))
410 .collect::<HashMap<_, _>>();
411 let source_samples = source_faces
412 .iter()
413 .map(|face| {
414 let standalone = standalone_face(source, face.id)?;
415 Ok((face.id, edge_sample_points(&standalone)?))
416 })
417 .collect::<Result<HashMap<_, _>, String>>()?;
418 if debug_enabled() {
419 for (index, carrier) in carriers.iter().enumerate() {
420 let face = &carrier.solid.shells[0].faces[0];
421 let samples = &carrier_samples[index];
422 let bounds =
423 samples
424 .iter()
425 .fold((Vec3::default(), Vec3::default()), |(low, high), point| {
426 (
427 Vec3 {
428 x: low.x.min(point.x),
429 y: low.y.min(point.y),
430 z: low.z.min(point.z),
431 },
432 Vec3 {
433 x: high.x.max(point.x),
434 y: high.y.max(point.y),
435 z: high.z.max(point.z),
436 },
437 )
438 });
439 let surface = &face.surface;
440 let rational = surface
441 .control_points
442 .iter()
443 .flatten()
444 .any(|point| (point.w - 1.0).abs() > 1e-12);
445 os_debug!(
446 "carrier[{index}] kind={:?} source_face={} loops={} deg=({},{}) net={}x{} rational={rational} bounds=({:.3},{:.3},{:.3})..({:.3},{:.3},{:.3})",
447 carrier.kind, carrier.source_face_id, face.loops.len(),
448 surface.degree_u, surface.degree_v,
449 surface.control_points.len(),
450 surface.control_points.first().map(|row| row.len()).unwrap_or(0),
451 bounds.0.x, bounds.0.y, bounds.0.z, bounds.1.x, bounds.1.y, bounds.1.z,
452 );
453 }
454 for (index, carrier) in carriers.iter().enumerate() {
455 if !matches!(carrier.kind, OffsetFaceRole::Offset) {
456 continue;
457 }
458 let source_face = source_by_id[&carrier.source_face_id];
459 let carrier_surface = &carrier.solid.shells[0].faces[0].surface;
460 let (ku, kv) = (
461 crate::KnotVector::new(
462 source_face.surface.knots_u.clone(),
463 source_face.surface.degree_u,
464 ),
465 crate::KnotVector::new(
466 source_face.surface.knots_v.clone(),
467 source_face.surface.degree_v,
468 ),
469 );
470 if let (Ok(ku), Ok(kv)) = (ku, kv) {
471 let [u0, u1] = ku.domain();
472 let [v0, v1] = kv.domain();
473 let mut worst = 0.0f64;
474 let mut worst_at = (0.0f64, 0.0f64);
475 for iu in 0..=24 {
476 for iv in 0..=24 {
477 let u = u0 + (u1 - u0) * iu as f64 / 24.0;
478 let v = v0 + (v1 - v0) * iv as f64 / 24.0;
479 let expected = face_offsets(source_face)
480 .at(u, v, -distance)
481 .map(|sample| sample.point);
482 let actual = carrier_surface.evaluate(u, v);
483 if let (Ok(expected), Ok(actual)) = (expected, actual) {
484 let error = expected.sub(actual).length();
485 if error > worst {
486 worst = error;
487 worst_at = (u, v);
488 }
489 }
490 }
491 }
492 let mut normal_swing = 0.0f64;
493 let mut previous: Option<Vec3> = None;
494 for iu in 0..=48 {
495 let u = u0 + (u1 - u0) * iu as f64 / 48.0;
496 let v = (v0 + v1) / 2.0;
497 if let Ok(normal) = face_normal(source_face, u, v) {
498 if let Some(previous) = previous {
499 normal_swing = normal_swing.max(previous.sub(normal).length());
500 }
501 previous = Some(normal);
502 }
503 }
504 let weights = source_face
505 .surface
506 .control_points
507 .iter()
508 .flatten()
509 .map(|point| point.w)
510 .fold((f64::MAX, f64::MIN), |(low, high), w| {
511 (low.min(w), high.max(w))
512 });
513 os_debug!(
514 "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={:?}",
515 worst_at.0, worst_at.1,
516 source_face.surface.control_points.len(),
517 source_face.surface.control_points.first().map(|row| row.len()).unwrap_or(0),
518 source_face.surface.degree_u, source_face.surface.degree_v,
519 weights.0, weights.1,
520 &source_face.surface.knots_u,
521 );
522 }
523 }
524 os_debug!("smooth pairs: {:?}", smooth.pairs);
525 }
526 let mut imprint = empty_imprint();
527 let mut next_piece_id = 1;
528 let mut next_vertex_id = 1;
529 for first in 0..carriers.len() {
530 for second in first + 1..carriers.len() {
531 let first_source = source_by_id[&carriers[first].source_face_id];
532 let second_source = source_by_id[&carriers[second].source_face_id];
533 if matches!(carriers[first].kind, OffsetFaceRole::Wall)
534 && matches!(carriers[second].kind, OffsetFaceRole::Wall)
535 {
536 if distance > 0.0 || !source_faces_adjacent(first_source, second_source) {
548 continue;
549 }
550 }
551 if smooth.pairs.contains(&(first, second)) {
552 os_debug!("pair ({first},{second}) skipped: smooth");
553 continue;
554 }
555 if !carrier_bounds[first].intersects(carrier_bounds[second], pair_tolerance) {
556 os_debug!("pair ({first},{second}) skipped: bounds");
557 continue;
558 }
559 if !source_faces_adjacent(first_source, second_source)
560 && sample_separation(
561 &source_samples[&first_source.id],
562 &source_samples[&second_source.id],
563 ) > reach
564 {
565 os_debug!("pair ({first},{second}) skipped: separation");
566 continue;
567 }
568 let pair = match build_imprints(
569 &carriers[first].solid,
570 &carriers[second].solid,
571 &ImprintOptions {
572 tolerance,
573 maximum_fit_points: 96,
574 local_fit: true,
575 fit_chunk_points: None,
576 maximum_ssi_step: Some((scale * 0.0005).max(tolerance * 100.0)),
582 },
583 ) {
584 Ok(pair) => pair,
585 Err(error) => {
593 os_debug!("pair ({first},{second}) imprint failed: {error}");
594 continue;
595 }
596 };
597 let pieces_before = imprint.pieces.len();
598 merge_pair_imprint(
599 &mut imprint,
600 pair,
601 first as u8,
602 second as u8,
603 &mut next_piece_id,
604 &mut next_vertex_id,
605 tolerance,
606 scale,
607 );
608 os_debug!(
609 "pair ({first},{second}) src=({},{}) pieces+={}",
610 carriers[first].source_face_id,
611 carriers[second].source_face_id,
612 imprint.pieces.len() - pieces_before,
613 );
614 }
615 }
616 if distance < 0.0 {
617 for index in 0..carriers.len() {
628 if !matches!(carriers[index].kind, OffsetFaceRole::Wall) {
629 continue;
630 }
631 let opening = source_by_id[&carriers[index].source_face_id];
632 for retained_id in &retained {
633 let retained_face = source_by_id[retained_id];
634 if !source_faces_adjacent(opening, retained_face) {
635 continue;
636 }
637 let neighbor = standalone_face(source, *retained_id)?;
638 let pair = match build_imprints(
639 &carriers[index].solid,
640 &neighbor,
641 &ImprintOptions {
642 tolerance,
643 maximum_fit_points: 96,
644 local_fit: true,
645 fit_chunk_points: None,
646 maximum_ssi_step: Some((scale * 0.0005).max(tolerance * 100.0)),
647 },
648 ) {
649 Ok(pair) => pair,
650 Err(error) => {
651 os_debug!(
652 "wall×source pair ({index},{retained_id}) imprint failed: {error}"
653 );
654 continue;
655 }
656 };
657 let pieces_before = imprint.pieces.len();
658 merge_pair_imprint(
659 &mut imprint,
660 pair,
661 index as u8,
662 SOURCE_OPERAND,
663 &mut next_piece_id,
664 &mut next_vertex_id,
665 tolerance,
666 scale,
667 );
668 os_debug!(
669 "wall×source pair ({index},{retained_id}) pieces+={}",
670 imprint.pieces.len() - pieces_before,
671 );
672 }
673 }
674 }
675 for (cone_index, cap_index) in &apex_cap_pairs {
682 let cap_face_id = carriers[*cap_index].solid.shells[0].faces[0].id;
683 let piece_ids = imprint
684 .by_face
685 .iter()
686 .find(|entry| entry.operand == *cap_index as u8 && entry.face_id == cap_face_id)
687 .map(|entry| entry.piece_ids.clone())
688 .unwrap_or_default();
689 let coincidence_band = 2e-5f64.max(scale * 1e-7);
690 for piece_id in piece_ids {
691 let Some((curve, t0, t1)) = imprint
692 .pieces
693 .iter()
694 .find(|piece| piece.id == piece_id)
695 .map(|piece| (piece.curve.clone(), piece.t0, piece.t1))
696 else {
697 continue;
698 };
699 for endpoint in [curve.evaluate(t0)?, curve.evaluate(t1)?] {
700 for edge in &carriers[*cone_index].solid.edges {
701 if edge.degenerate {
702 continue;
703 }
704 let projection = crate::project_point_to_curve(&edge.curve, endpoint)?;
705 if projection.distance > coincidence_band
706 || projection.u < edge.t0 + 1e-9
707 || projection.u > edge.t1 - 1e-9
708 {
709 continue;
710 }
711 let parameter = projection.u;
712 if let Some(existing) = imprint.edge_splits.iter_mut().find(|split| {
713 split.operand == *cone_index as u8 && split.edge_id == edge.id
714 }) {
715 if !existing
716 .parameters
717 .iter()
718 .any(|value| (*value - parameter).abs() <= 1e-8)
719 {
720 existing.parameters.push(parameter);
721 }
722 } else {
723 imprint.edge_splits.push(EdgeSplitRecord {
724 operand: *cone_index as u8,
725 edge_id: edge.id,
726 parameters: vec![parameter],
727 });
728 }
729 os_debug!(
730 "apex-cap ring split: cone carrier {cone_index} edge {} at t={parameter:.6}",
731 edge.id,
732 );
733 }
734 }
735 }
736 }
737 if debug_enabled() {
738 for piece in &imprint.pieces {
739 let start = piece.curve.evaluate(piece.t0);
740 let mid = piece.curve.evaluate((piece.t0 + piece.t1) * 0.5);
741 let end = piece.curve.evaluate(piece.t1);
742 if let (Ok(start), Ok(mid), Ok(end)) = (start, mid, end) {
743 os_debug!(
744 "piece[{}] ({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})",
745 piece.id,
746 start.x,
747 start.y,
748 start.z,
749 mid.x,
750 mid.y,
751 mid.z,
752 end.x,
753 end.y,
754 end.z,
755 );
756 }
757 }
758 }
759 synchronize_smooth_edge_splits(&mut imprint, &smooth.edge_pairs);
760 let boundary_coincidence_tolerance = 2e-5f64.max(scale * 1e-7);
765 let piece_geometry = imprint
766 .pieces
767 .iter()
768 .map(|piece| {
769 (
770 piece.id,
771 FragmentEdgeGeometry {
772 curve: piece.curve.clone(),
773 t0: piece.t0,
774 t1: piece.t1,
775 },
776 )
777 })
778 .collect::<HashMap<_, _>>();
779 for (index, carrier) in carriers.iter().enumerate() {
780 if !matches!(carrier.kind, OffsetFaceRole::Offset) {
781 continue;
782 }
783 let boundaries = carrier
784 .solid
785 .edges
786 .iter()
787 .map(|edge| FragmentEdgeGeometry {
788 curve: edge.curve.clone(),
789 t0: edge.t0,
790 t1: edge.t1,
791 })
792 .collect::<Vec<_>>();
793 if let Some(by_face) = imprint
794 .by_face
795 .iter_mut()
796 .find(|entry| entry.operand == index as u8)
797 {
798 let before = by_face.piece_ids.clone();
799 by_face.piece_ids.retain(|piece_id| {
800 let piece = &piece_geometry[piece_id];
801 !boundaries.iter().any(|boundary| {
802 fragment_edge_lies_on(piece, boundary, boundary_coincidence_tolerance)
803 .unwrap_or(false)
804 })
805 });
806 if debug_enabled() && before.len() != by_face.piece_ids.len() {
807 os_debug!(
808 "carrier[{index}] dropped boundary-coincident pieces: {:?} -> {:?}",
809 before,
810 by_face.piece_ids,
811 );
812 }
813 }
814 }
815
816 if debug_enabled() {
817 for entry in &imprint.by_face {
818 os_debug!(
819 "by_face operand={} face={} pieces={:?}",
820 entry.operand,
821 entry.face_id,
822 entry.piece_ids,
823 );
824 }
825 }
826 let mut split_carriers = Vec::new();
827 let mut fragments_by_carrier = Vec::new();
828 for (index, carrier) in carriers.iter().enumerate() {
829 let split = if matches!(carrier.kind, OffsetFaceRole::Offset)
836 && !apex_cap_sources.contains(&index)
837 {
838 carrier.solid.clone()
839 } else {
840 apply_edge_splits(&carrier.solid, index as u8, &imprint)?
841 };
842 let fragments = fragment_solid(&split, index as u8, &imprint)?;
843 os_debug!(
844 "carrier[{index}] kind={:?} src={} fragments={}",
845 carrier.kind,
846 carrier.source_face_id,
847 fragments.len(),
848 );
849 if debug_enabled() {
850 for (fragment_index, fragment) in fragments.iter().enumerate() {
851 let mut boundary_count = 0usize;
852 let mut imprint_pieces = Vec::new();
853 let mut derived_count = 0usize;
854 for coedge in fragment
855 .loops
856 .iter()
857 .flat_map(|loop_record| &loop_record.coedges)
858 {
859 match &coedge.source {
860 FragmentEdgeSource::Boundary { .. }
861 | FragmentEdgeSource::SharedBoundary { .. } => boundary_count += 1,
862 FragmentEdgeSource::Imprint { piece_id } => imprint_pieces.push(*piece_id),
863 FragmentEdgeSource::Derived { .. } => derived_count += 1,
864 }
865 }
866 os_debug!(
867 " frag[{index}.{fragment_index}] test=({:.3},{:.3},{:.3}) uv=({:.3},{:.3}) boundary={boundary_count} derived={derived_count} pieces={imprint_pieces:?}",
868 fragment.test_point.x, fragment.test_point.y, fragment.test_point.z,
869 fragment.test_uv.x, fragment.test_uv.y,
870 );
871 }
872 }
873 split_carriers.push(split);
874 fragments_by_carrier.push(fragments);
875 }
876 let mut assembly_sources = [(SOURCE_OPERAND, source)]
877 .into_iter()
878 .collect::<HashMap<_, _>>();
879 for (index, carrier) in split_carriers.iter().enumerate() {
880 assembly_sources.insert(index as u8, carrier);
881 }
882
883 let source_edge_by_id = source
884 .edges
885 .iter()
886 .map(|edge| (edge.id, edge))
887 .collect::<HashMap<_, _>>();
888 let retained_faces_with_edges = retained
889 .iter()
890 .map(|face_id| {
891 let face = source_by_id[face_id];
892 let edges = face
893 .loops
894 .iter()
895 .flat_map(|loop_record| &loop_record.coedges)
896 .filter_map(|coedge| source_edge_by_id.get(&coedge.edge_id).copied())
897 .collect::<Vec<_>>();
898 (face, edges)
899 })
900 .collect::<Vec<_>>();
901 let offset_skin_tolerance = 2e-3f64.max(scale * 5e-5).max(distance.abs() * 1e-3);
902 let mut opening_planes: Vec<(usize, Vec3, Vec3)> = Vec::new();
912 if distance < 0.0 {
913 for (index, carrier) in carriers.iter().enumerate() {
914 if !matches!(carrier.kind, OffsetFaceRole::Wall) {
915 continue;
916 }
917 let opening = source_by_id[&carrier.source_face_id];
918 if !opening.surface.is_affine()? {
919 continue;
920 }
921 let [u0, u1] = opening.surface.domain_u()?;
922 let [v0, v1] = opening.surface.domain_v()?;
923 let (u, v) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
924 opening_planes.push((
925 index,
926 opening.surface.evaluate(u, v)?,
927 face_normal(opening, u, v)?,
928 ));
929 }
930 }
931 let in_front_of_other_opening = |wall_index: usize, point: Vec3| -> Result<bool, String> {
932 let band = 2e-3f64.max(scale * 5e-5);
933 for (index, plane_point, outward) in &opening_planes {
934 if *index == wall_index || point.sub(*plane_point).dot(*outward) <= band {
935 continue;
936 }
937 let face = &split_carriers[*index].shells[0].faces[0];
938 let projection = project_point_to_surface(&face.surface, point)?;
939 let uv = Vec2 {
940 x: projection.u,
941 y: projection.v,
942 };
943 if parameter_point_in_face(face, uv, 1e-8)? != PolygonClass::Outside {
944 return Ok(true);
945 }
946 }
947 Ok(false)
948 };
949
950 let mut chosen_offsets = Vec::new();
951 let mut chosen_walls = Vec::new();
952 for (index, carrier) in carriers.iter().enumerate() {
953 let raw_seed = source_seed(source, carrier.source_face_id)?;
954 let seed = if matches!(carrier.kind, OffsetFaceRole::Offset) {
955 offset_seed(
956 source_by_id[&carrier.source_face_id],
957 raw_seed,
958 &split_carriers[index].shells[0].faces[0].surface,
959 distance,
960 2e-3f64.max(scale * 5e-5),
961 )?
962 .unwrap_or(raw_seed)
963 } else {
964 raw_seed
965 };
966 if seed.sub(raw_seed).length() > 1e-9 {
967 os_debug!(
968 " seed[{index}] moved from ({:.4},{:.4}) to the offset image ({:.4},{:.4})",
969 raw_seed.x,
970 raw_seed.y,
971 seed.x,
972 seed.y
973 );
974 }
975 if matches!(carrier.kind, OffsetFaceRole::Offset) {
976 let on_skin = fragments_by_carrier[index]
982 .iter()
983 .map(|fragment| {
984 point_on_offset_skin(
985 fragment.test_point,
986 &retained_faces_with_edges,
987 distance,
988 offset_skin_tolerance,
989 )
990 })
991 .collect::<Result<Vec<_>, String>>()?;
992 let skin_filter_active = on_skin.iter().any(|flag| *flag);
993 let mut classified = Vec::new();
994 let mut viable = Vec::new();
995 for (fragment_index, fragment) in fragments_by_carrier[index].iter().enumerate() {
996 let class = classify_point(fragment.test_point, source, tolerance * 10.0)?.class;
997 let excluded_by_class = (distance > 0.0 && class == PointClass::Out)
998 || (distance < 0.0 && class == PointClass::In);
999 let contact = if excluded_by_class {
1000 false
1001 } else {
1002 distance > 0.0
1003 && fragment_has_sustained_source_contact(
1004 fragment,
1005 &source_faces,
1006 &assembly_sources,
1007 &imprint,
1008 2e-3f64.max(scale * 5e-5),
1009 )?
1010 };
1011 os_debug!(
1012 " select[{index}.{fragment_index}] class={class:?} contact={contact} on_skin={} seed_in={:?} uv=({:.3},{:.3})",
1013 on_skin[fragment_index],
1014 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8),
1015 fragment.test_uv.x, fragment.test_uv.y,
1016 );
1017 if excluded_by_class || (skin_filter_active && !on_skin[fragment_index]) {
1018 continue;
1019 }
1020 classified.push(fragment.clone());
1021 if contact {
1022 continue;
1023 }
1024 viable.push(fragment.clone());
1025 }
1026 os_debug!(" select[{index}] seed=({:.4},{:.4})", seed.x, seed.y);
1027 let seeded = viable
1028 .iter()
1029 .filter(|fragment| {
1030 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)
1031 .is_ok_and(|class| class != PolygonClass::Outside)
1032 })
1033 .cloned()
1034 .collect::<Vec<_>>();
1035 let seeded_classified = classified
1036 .iter()
1037 .filter(|fragment| {
1038 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)
1039 .is_ok_and(|class| class != PolygonClass::Outside)
1040 })
1041 .cloned()
1042 .collect::<Vec<_>>();
1043 let selected = if viable.is_empty() {
1044 if !skin_filter_active {
1045 None
1055 } else {
1056 let mut by_area = classified
1057 .into_iter()
1058 .map(|fragment| {
1059 let area = parameter_space_area(&fragment_as_trim(&fragment))?.abs();
1060 Ok((fragment, area))
1061 })
1062 .collect::<Result<Vec<_>, String>>()?;
1063 by_area.sort_by(|a, b| b.1.total_cmp(&a.1));
1064 by_area.into_iter().next().map(|entry| entry.0)
1065 }
1066 } else if !seeded_classified.is_empty() {
1067 let mut candidates = seeded_classified;
1068 candidates.sort_by(|a, b| {
1069 a.test_uv
1070 .sub(seed)
1071 .length()
1072 .total_cmp(&b.test_uv.sub(seed).length())
1073 });
1074 candidates.into_iter().next()
1075 } else if viable.len() > 2 {
1076 let mut by_area = viable
1077 .into_iter()
1078 .map(|fragment| {
1079 let area = parameter_space_area(&fragment_as_trim(&fragment))?.abs();
1080 Ok((fragment, area))
1081 })
1082 .collect::<Result<Vec<_>, String>>()?;
1083 by_area.sort_by(|a, b| b.1.total_cmp(&a.1));
1084 by_area.into_iter().next().map(|entry| entry.0)
1085 } else {
1086 let mut candidates = if seeded.is_empty() { viable } else { seeded };
1087 candidates.sort_by(|a, b| {
1088 a.test_uv
1089 .sub(seed)
1090 .length()
1091 .total_cmp(&b.test_uv.sub(seed).length())
1092 });
1093 candidates.into_iter().next()
1094 };
1095 if let Some(fragment) = selected {
1096 chosen_offsets.push(fragment);
1097 }
1098 } else {
1099 let opening = source_by_id[&carrier.source_face_id];
1103 let wall_seeds = opening_wall_seeds(
1104 opening,
1105 &split_carriers[index].shells[0].faces[0],
1106 source,
1107 &opening_set,
1108 distance,
1109 )?;
1110 let carrier_planar =
1118 surface_is_planar(&split_carriers[index].shells[0].faces[0].surface, tolerance)
1119 .unwrap_or(false);
1120 let mut selected_indices = Vec::new();
1121 for (fragment_index, fragment) in fragments_by_carrier[index].iter().enumerate() {
1122 if parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)?
1123 == PolygonClass::Outside
1124 {
1125 if in_front_of_other_opening(index, fragment.test_point)? {
1126 os_debug!(
1127 " wall[{index}.{fragment_index}] skipped: in front of another \
1128 opening at ({:.3},{:.3},{:.3})",
1129 fragment.test_point.x,
1130 fragment.test_point.y,
1131 fragment.test_point.z,
1132 );
1133 continue;
1134 }
1135 if point_on_offset_skin(
1148 fragment.test_point,
1149 &retained_faces_with_edges,
1150 distance,
1151 offset_skin_tolerance,
1152 )? && !(carrier_planar
1153 && wall_seeds.iter().any(|wall_seed| {
1154 parameter_point_in_face(&fragment_as_trim(fragment), *wall_seed, 1e-8)
1155 .is_ok_and(|class| class != PolygonClass::Outside)
1156 }))
1157 {
1158 os_debug!(
1159 " wall[{index}.{fragment_index}] skipped: void cross-section at \
1160 ({:.3},{:.3},{:.3})",
1161 fragment.test_point.x,
1162 fragment.test_point.y,
1163 fragment.test_point.z,
1164 );
1165 continue;
1166 }
1167 selected_indices.push(fragment_index);
1168 }
1169 }
1170 for wall_seed in wall_seeds {
1171 let fragments = &fragments_by_carrier[index];
1172 let nearest_containing = fragments
1173 .iter()
1174 .enumerate()
1175 .filter(|(_, fragment)| {
1176 parameter_point_in_face(&fragment_as_trim(fragment), wall_seed, 1e-8)
1177 .is_ok_and(|class| class != PolygonClass::Outside)
1178 })
1179 .min_by(|(_, first), (_, second)| {
1180 first
1181 .test_uv
1182 .sub(wall_seed)
1183 .length()
1184 .total_cmp(&second.test_uv.sub(wall_seed).length())
1185 })
1186 .map(|(fragment_index, _)| fragment_index);
1187 let nearest = if nearest_containing.is_some() {
1192 nearest_containing
1193 } else {
1194 let seed_point = split_carriers[index].shells[0].faces[0]
1195 .surface
1196 .evaluate(wall_seed.x, wall_seed.y)?;
1197 fragments
1198 .iter()
1199 .enumerate()
1200 .min_by(|(_, first), (_, second)| {
1201 first
1202 .test_point
1203 .sub(seed_point)
1204 .length()
1205 .total_cmp(&second.test_point.sub(seed_point).length())
1206 })
1207 .map(|(fragment_index, _)| fragment_index)
1208 };
1209 if let Some(fragment_index) = nearest {
1210 let candidate = &fragments_by_carrier[index][fragment_index];
1211 if in_front_of_other_opening(index, candidate.test_point)? {
1212 continue;
1213 }
1214 if (nearest_containing.is_none() || !carrier_planar)
1223 && point_on_offset_skin(
1224 candidate.test_point,
1225 &retained_faces_with_edges,
1226 distance,
1227 offset_skin_tolerance,
1228 )?
1229 {
1230 os_debug!(
1231 " wall[{index}.{fragment_index}] seed-hit skipped: void \
1232 cross-section (planar={carrier_planar}) at ({:.3},{:.3},{:.3})",
1233 candidate.test_point.x,
1234 candidate.test_point.y,
1235 candidate.test_point.z,
1236 );
1237 continue;
1238 }
1239 let existing = chosen_offsets.iter().cloned().chain(
1240 selected_indices
1241 .iter()
1242 .map(|selected| fragments_by_carrier[index][*selected].clone()),
1243 );
1244 if !selected_indices.contains(&fragment_index)
1245 && !would_overuse_existing_boundary(
1246 candidate,
1247 existing,
1248 &assembly_sources,
1249 &imprint,
1250 2e-3,
1251 )?
1252 {
1253 selected_indices.push(fragment_index);
1254 }
1255 }
1256 }
1257 for fragment_index in selected_indices.iter().copied() {
1258 chosen_walls.push(fragments_by_carrier[index][fragment_index].clone());
1259 }
1260 }
1261 }
1262 if debug_enabled() {
1263 for fragment in &chosen_offsets {
1264 os_debug!(
1265 "chosen offset: operand={} src={} test=({:.3},{:.3},{:.3})",
1266 fragment.operand,
1267 carriers[fragment.operand as usize].source_face_id,
1268 fragment.test_point.x,
1269 fragment.test_point.y,
1270 fragment.test_point.z,
1271 );
1272 for (loop_index, loop_record) in fragment.loops.iter().enumerate() {
1273 for coedge in &loop_record.coedges {
1274 let surface = &fragment.surface;
1275 let [c0, c1] = coedge.pcurve.domain().unwrap_or([0.0, 0.0]);
1276 let uv0 = coedge.pcurve.evaluate(c0);
1277 let uv1 = coedge.pcurve.evaluate(c1);
1278 if let (Ok(uv0), Ok(uv1)) = (uv0, uv1) {
1279 let p0 = surface.evaluate(uv0.x, uv0.y);
1280 let p1 = surface.evaluate(uv1.x, uv1.y);
1281 if let (Ok(p0), Ok(p1)) = (p0, p1) {
1282 let kind = match &coedge.source {
1283 FragmentEdgeSource::Boundary { edge_id, .. } => {
1284 format!("bnd:{edge_id}")
1285 }
1286 FragmentEdgeSource::SharedBoundary { edge_id, .. } => {
1287 format!("sbnd:{edge_id}")
1288 }
1289 FragmentEdgeSource::Imprint { piece_id } => {
1290 format!("imp:{piece_id}")
1291 }
1292 FragmentEdgeSource::Derived { .. } => "derived".to_string(),
1293 };
1294 os_debug!(
1295 " loop{loop_index} {kind} ({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})",
1296 p0.x, p0.y, p0.z, p1.x, p1.y, p1.z,
1297 );
1298 }
1299 }
1300 }
1301 }
1302 }
1303 for fragment in &chosen_walls {
1304 os_debug!(
1305 "chosen wall: operand={} src={} test=({:.3},{:.3},{:.3})",
1306 fragment.operand,
1307 carriers[fragment.operand as usize].source_face_id,
1308 fragment.test_point.x,
1309 fragment.test_point.y,
1310 fragment.test_point.z,
1311 );
1312 }
1313 }
1314 if chosen_offsets.is_empty() {
1315 return Err("offset_shell: carrier intersections produced no offset boundary".into());
1316 }
1317 let mut boundary_use_count = HashMap::<[i64; 9], usize>::default();
1321 for fragment in &chosen_offsets {
1322 for coedge in fragment
1323 .loops
1324 .iter()
1325 .flat_map(|loop_record| &loop_record.coedges)
1326 {
1327 let key = fragment_edge_segment_key(&coedge.source, &assembly_sources, &imprint)?;
1328 *boundary_use_count.entry(key).or_default() += 1;
1329 }
1330 }
1331 let mut manifold_walls = Vec::new();
1332 for fragment in chosen_walls {
1333 let keys = fragment
1334 .loops
1335 .iter()
1336 .flat_map(|loop_record| &loop_record.coedges)
1337 .map(|coedge| fragment_edge_segment_key(&coedge.source, &assembly_sources, &imprint))
1338 .collect::<Result<Vec<_>, _>>()?;
1339 if !keys
1340 .iter()
1341 .any(|key| boundary_use_count.get(key).copied().unwrap_or(0) >= 2)
1342 {
1343 for key in keys {
1344 *boundary_use_count.entry(key).or_default() += 1;
1345 }
1346 manifold_walls.push(fragment);
1347 }
1348 }
1349 os_debug!(
1350 "manifold walls kept: {} operands={:?}",
1351 manifold_walls.len(),
1352 manifold_walls
1353 .iter()
1354 .map(|fragment| fragment.operand)
1355 .collect::<Vec<_>>(),
1356 );
1357 let chosen_walls = manifold_walls;
1358
1359 let source_fragments = fragment_solid(source, SOURCE_OPERAND, &empty_imprint())?;
1360 let mut selected = source_fragments
1361 .into_iter()
1362 .filter(|fragment| retained.contains(&fragment.source_face_id))
1363 .collect::<Vec<_>>();
1364 let mut face_images = selected
1365 .iter()
1366 .map(|fragment| OffsetShellFaceImageRecord {
1367 role: OffsetFaceRole::Source,
1368 source_face_id: fragment.source_face_id,
1369 })
1370 .collect::<Vec<_>>();
1371 if distance < 0.0 {
1372 for fragment in &mut selected {
1373 flip_fragment(fragment)?;
1374 }
1375 }
1376 if distance > 0.0 {
1377 for fragment in &mut chosen_offsets {
1378 flip_fragment(fragment)?;
1379 }
1380 }
1381 face_images.extend(
1382 chosen_offsets
1383 .iter()
1384 .map(|fragment| OffsetShellFaceImageRecord {
1385 role: OffsetFaceRole::Offset,
1386 source_face_id: carriers[fragment.operand as usize].source_face_id,
1387 }),
1388 );
1389 face_images.extend(
1390 chosen_walls
1391 .iter()
1392 .map(|fragment| OffsetShellFaceImageRecord {
1393 role: OffsetFaceRole::Wall,
1394 source_face_id: carriers[fragment.operand as usize].source_face_id,
1395 }),
1396 );
1397 selected.extend(chosen_offsets);
1398 selected.extend(chosen_walls);
1399 let mut solid = assemble_open_fragments(
1400 selected,
1401 &assembly_sources,
1402 &imprint,
1403 2e-3f64.max(scale * 5e-5),
1404 )?;
1405 orient_open_solid_faces(&mut solid)?;
1406 let connector_images = complete_sharp_offset_connectors(
1407 &mut solid,
1408 source,
1409 &face_images,
1410 distance,
1411 2e-3f64.max(scale * 5e-5),
1412 )?;
1413 os_debug!(
1414 "sharp connector completion added {} faces",
1415 connector_images.len()
1416 );
1417 if !connector_images.is_empty() {
1418 for shell in &mut solid.shells {
1423 shell.faces.retain(|face| {
1424 !matches!(
1425 face_images
1426 .get(face.id.saturating_sub(1) as usize)
1427 .map(|image| image.role),
1428 Some(OffsetFaceRole::Wall)
1429 )
1430 });
1431 }
1432 solid.shells.retain(|shell| !shell.faces.is_empty());
1433 }
1434 face_images.extend(connector_images);
1435 orient_open_solid_faces(&mut solid)?;
1436 let welded_rims = weld_coplanar_orphan_rims(&mut solid, 2e-3f64.max(scale * 5e-5))?;
1440 os_debug!("welded {welded_rims} coplanar orphan rims into opening caps");
1441 let ruled_rims = weld_ruled_offset_rims(&mut solid, 2e-3f64.max(scale * 5e-5))?;
1444 os_debug!("welded {ruled_rims} non-coplanar offset rims into ruled bands");
1445 let pair_rims =
1448 weld_coplanar_rim_pair_annuli(&mut solid, &mut face_images, 2e-3f64.max(scale * 5e-5))?;
1449 os_debug!("welded {pair_rims} coplanar rim pairs into annulus walls");
1450 if debug_enabled() {
1451 for (shell_index, shell) in solid.shells.iter().enumerate() {
1452 for face in &shell.faces {
1453 os_debug!(
1454 "POSTWELD shell {} face {} same_sense={} loops={}",
1455 shell_index,
1456 face.id,
1457 face.same_sense,
1458 face.loops.len()
1459 );
1460 for (loop_index, loop_record) in face.loops.iter().enumerate() {
1461 let walk = loop_record
1462 .coedges
1463 .iter()
1464 .map(|coedge| {
1465 let spin = (|| -> Result<f64, String> {
1466 let [d0, d1] = coedge.pcurve.domain()?;
1467 let a = coedge.pcurve.evaluate(d0 + (d1 - d0) * 0.45)?;
1468 let b = coedge.pcurve.evaluate(d0 + (d1 - d0) * 0.55)?;
1469 let pa = face.surface.evaluate(a.x, a.y)?;
1470 let pb = face.surface.evaluate(b.x, b.y)?;
1471 Ok(pa.cross(pb).z)
1472 })()
1473 .unwrap_or(f64::NAN);
1474 format!(
1475 "{}{}(spin{:+.0})",
1476 if coedge.forward { "+" } else { "-" },
1477 coedge.edge_id,
1478 spin.signum()
1479 )
1480 })
1481 .collect::<Vec<_>>();
1482 os_debug!(" loop {} {:?}", loop_index, walk);
1483 }
1484 }
1485 }
1486 }
1487 let completion_carriers = carriers.iter().collect::<Vec<_>>();
1488 let completion_tolerance = 2e-3f64.max(scale * 5e-5);
1489 let duplicate_welds = weld_duplicate_one_use_arcs(&mut solid, completion_tolerance)?;
1493 os_debug!("welded {duplicate_welds} duplicate one-use boundary arcs");
1494 if debug_enabled() {
1495 let mut use_counts = HashMap::<u64, usize>::default();
1496 for coedge in solid
1497 .shells
1498 .iter()
1499 .flat_map(|shell| &shell.faces)
1500 .flat_map(|face| &face.loops)
1501 .flat_map(|loop_record| &loop_record.coedges)
1502 {
1503 *use_counts.entry(coedge.edge_id).or_default() += 1;
1504 }
1505 let open = use_counts.values().filter(|count| **count == 1).count();
1506 os_debug!(
1507 "assembled: faces={} open_edges={open}",
1508 solid
1509 .shells
1510 .iter()
1511 .map(|shell| shell.faces.len())
1512 .sum::<usize>()
1513 );
1514 }
1515 let completed_images = complete_opening_boundary_cycles(
1516 &mut solid,
1517 &completion_carriers,
1518 &face_images,
1519 completion_tolerance,
1520 )?;
1521 os_debug!("completion added {} faces", completed_images.len());
1522 face_images.extend(completed_images);
1523 let solid = merge_same_surface_faces_open(&solid, (1e-7f64).max(scale * 1e-9))?;
1524 let assembly_tolerance = 2e-3f64.max(scale * 5e-5);
1525 let mut solid = finalize_assembled_solid(solid, assembly_tolerance)?;
1529 if ruled_rims + pair_rims > 0 {
1530 coheres_face_normals(&mut solid)?;
1535 }
1536 let solid = solid;
1537 face_images = solid
1538 .shells
1539 .iter()
1540 .flat_map(|shell| &shell.faces)
1541 .map(|face| {
1542 face_images
1543 .get(face.id.saturating_sub(1) as usize)
1544 .cloned()
1545 .ok_or_else(|| "offset_shell: merged face lost provenance".to_string())
1546 })
1547 .collect::<Result<Vec<_>, _>>()?;
1548 let bore_rings = claim_bore_end_rings(&solid, &mut face_images)?;
1552 os_debug!("{bore_rings} bore end rings took the bore wall's provenance");
1553 let mut rim_use_counts = HashMap::<u64, usize>::default();
1560 for coedge in solid
1561 .shells
1562 .iter()
1563 .flat_map(|shell| &shell.faces)
1564 .flat_map(|face| &face.loops)
1565 .flat_map(|loop_record| &loop_record.coedges)
1566 {
1567 *rim_use_counts.entry(coedge.edge_id).or_default() += 1;
1568 }
1569 if debug_enabled() {
1570 for (shell_index, shell) in solid.shells.iter().enumerate() {
1571 for face in &shell.faces {
1572 let role = face_images
1573 .get(face.id.saturating_sub(1) as usize)
1574 .map(|image| image.role);
1575 let c = face.surface.evaluate(0.5, 0.5).unwrap_or_default();
1576 os_debug!(
1577 "DUMPFACE shell {} face {} affine={} role={:?} loops={} center=({:.3},{:.3},{:.3})",
1578 shell_index, face.id, face.surface.is_affine().unwrap_or(false), role,
1579 face.loops.len(), c.x, c.y, c.z
1580 );
1581 for (li, lp) in face.loops.iter().enumerate() {
1582 let ids = lp.coedges.iter().map(|c| c.edge_id).collect::<Vec<_>>();
1583 os_debug!(" loop {} edges {:?}", li, ids);
1584 }
1585 }
1586 }
1587 for edge in &solid.edges {
1588 let uses = rim_use_counts.get(&edge.id).copied().unwrap_or(0);
1589 let s = edge.curve.evaluate(edge.t0)?;
1590 let m = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
1591 let q = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25)?;
1592 os_debug!(
1593 "DUMPEDGE edge {} uses={} closed={} deg={} cpts={} cdeg={} knots={:?} start=({:.3},{:.3},{:.3}) q=({:.3},{:.3},{:.3}) mid=({:.3},{:.3},{:.3})",
1594 edge.id, uses, edge.start_vertex_id == edge.end_vertex_id, edge.degenerate,
1595 edge.curve.control_points.len(), edge.curve.degree, edge.curve.knots,
1596 s.x, s.y, s.z, q.x, q.y, q.z, m.x, m.y, m.z
1597 );
1598 }
1599 }
1600 for edge in &solid.edges {
1601 if rim_use_counts.get(&edge.id).copied().unwrap_or(0) != 1
1602 || edge.start_vertex_id != edge.end_vertex_id
1603 {
1604 continue;
1605 }
1606 let anchor = edge.curve.evaluate(edge.t0)?;
1607 let sweep_threshold = 1e-4f64.max(scale * 1e-6);
1608 let sweeps = [0.25, 0.5, 0.75].into_iter().any(|fraction| {
1609 edge.curve
1610 .evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction)
1611 .map(|point| point.sub(anchor).length() > sweep_threshold)
1612 .unwrap_or(false)
1613 });
1614 if sweeps {
1615 if debug_enabled() {
1616 let rim_mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
1617 os_debug!(
1618 "PROBE orphan rim edge {} anchor=({:.4},{:.4},{:.4}) mid=({:.4},{:.4},{:.4})",
1619 edge.id,
1620 anchor.x,
1621 anchor.y,
1622 anchor.z,
1623 rim_mid.x,
1624 rim_mid.y,
1625 rim_mid.z
1626 );
1627 for (shell_index, shell) in solid.shells.iter().enumerate() {
1628 for face in &shell.faces {
1629 let uses_rim = face
1630 .loops
1631 .iter()
1632 .flat_map(|l| &l.coedges)
1633 .any(|c| c.edge_id == edge.id);
1634 let affine = face.surface.is_affine().unwrap_or(false);
1635 let on = edge_on_surface(edge, &face.surface, 2e-3f64.max(scale * 5e-5))
1636 .unwrap_or(false);
1637 let proj = project_point_to_surface(&face.surface, rim_mid)?;
1638 let contains = if on {
1639 parameter_point_in_face(
1640 face,
1641 Vec2 {
1642 x: proj.u,
1643 y: proj.v,
1644 },
1645 2e-3f64.max(scale * 5e-5),
1646 )? != PolygonClass::Outside
1647 } else {
1648 false
1649 };
1650 if on || uses_rim {
1651 os_debug!(
1652 " face {} shell {} affine={} uses_rim={} on_surf={} proj_dist={:.5} contains={}",
1653 face.id, shell_index, affine, uses_rim, on, proj.distance, contains
1654 );
1655 }
1656 }
1657 }
1658 }
1659 return Err(format!(
1660 "offset_shell: unwelded rim leaves a non-watertight shell \
1661 (one-use closed edge {} near ({:.3},{:.3},{:.3})); this \
1662 curved-solid opening is not yet supported",
1663 edge.id, anchor.x, anchor.y, anchor.z
1664 ));
1665 }
1666 }
1667 Ok(OffsetShellResultRecord { solid, face_images })
1668}
1669
1670pub fn offset_shell_with_diagnostics(
1673 source: &BrepSolid,
1674 opening_face_ids: &[u64],
1675 distance: f64,
1676 tolerances: Option<KernelTolerances>,
1677) -> Result<KernelOutcome<OffsetShellResultRecord>, String> {
1678 let policy = tolerances.unwrap_or_else(|| KernelTolerances::for_solid(source, 1e-7));
1679 policy.check()?;
1680 let mut diagnostics = KernelDiagnostics::default();
1681 diagnostics.count_n(
1682 "collect.source_faces",
1683 source
1684 .shells
1685 .iter()
1686 .map(|shell| shell.faces.len() as u64)
1687 .sum(),
1688 );
1689 diagnostics.count_n("collect.opening_faces", opening_face_ids.len() as u64);
1690 diagnostics.measure_max("offset.distance", distance.abs());
1691 diagnostics.measure_max("tolerance.model", policy.model);
1692 let result = offset_shell(source, opening_face_ids, distance)?;
1693 diagnostics.count_n(
1694 "sew.output_faces",
1695 result
1696 .solid
1697 .shells
1698 .iter()
1699 .map(|shell| shell.faces.len() as u64)
1700 .sum(),
1701 );
1702 diagnostics.count_n("sew.face_images", result.face_images.len() as u64);
1703 let validation = result.solid.validate_detailed(&policy);
1704 diagnostics.measure_max("validate.max_pcurve_error", validation.max_pcurve_error);
1705 diagnostics.count_n("validate.issues", validation.issues.len() as u64);
1706 diagnostics.count_n(
1707 "validate.wire_warnings",
1708 validation.wire_warnings.len() as u64,
1709 );
1710 for warning in validation.wire_warnings {
1711 diagnostics.event(
1712 DiagnosticSeverity::Warning,
1713 KernelStage::Validate,
1714 "validate.uv_wire",
1715 warning.message,
1716 );
1717 }
1718 if !validation.issues.is_empty() {
1719 for issue in &validation.issues {
1720 diagnostics.event(
1721 DiagnosticSeverity::Error,
1722 KernelStage::Validate,
1723 "validate.brep",
1724 issue.message.clone(),
1725 );
1726 }
1727 return Err(format!(
1728 "offset_shell: invalid diagnostic result: {:?}",
1729 validation.issues
1730 ));
1731 }
1732 Ok(KernelOutcome {
1733 value: result,
1734 diagnostics,
1735 })
1736}