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 carriers.push(Carrier {
164 solid: carrier_solid(
165 source,
166 *face_id,
167 distance,
168 match (distance < 0.0, reflex_extension) {
169 (true, extension) => distance.abs().max(extension.unwrap_or(0.0)),
170 (false, Some(extension)) => extension,
171 (false, None) => 0.0,
172 },
173 )?,
174 source_face_id: *face_id,
175 kind: OffsetFaceRole::Offset,
176 });
177 }
178 let mut apex_cap_sources = HashSet::default();
187 let mut apex_cap_pairs: Vec<(usize, usize)> = Vec::new();
188 {
189 let retained_count = carriers.len();
190 for index in 0..retained_count {
191 let source_face = source_by_id_lookup(&source_faces, carriers[index].source_face_id);
192 let Some(source_face) = source_face else {
193 continue;
194 };
195 let carrier_surface = carriers[index].solid.shells[0].faces[0].surface.clone();
196 for coedge in source_face
197 .loops
198 .iter()
199 .flat_map(|loop_record| &loop_record.coedges)
200 {
201 let Some(edge) = source
202 .edges
203 .iter()
204 .find(|edge| edge.id == coedge.edge_id)
205 else {
206 continue;
207 };
208 if !edge.degenerate {
209 continue;
210 }
211 let [p0, p1] = coedge.pcurve.domain()?;
213 let mut ring = Vec::new();
214 for sample in 0..=8 {
215 let uv = coedge
216 .pcurve
217 .evaluate(p0 + (p1 - p0) * sample as f64 / 8.0)?;
218 ring.push(carrier_surface.evaluate(uv.x, uv.y)?);
219 }
220 let spread = ring
221 .iter()
222 .map(|point| point.sub(ring[0]).length())
223 .fold(0.0f64, f64::max);
224 if spread <= distance.abs() * 1e-2 {
225 continue;
226 }
227 let apex = source
228 .vertices
229 .iter()
230 .find(|vertex| vertex.id == edge.start_vertex_id)
231 .map(|vertex| vertex.point)
232 .ok_or_else(|| "offset_shell: apex vertex missing".to_string())?;
233 let centroid = ring
234 .iter()
235 .fold(Vec3::default(), |sum, point| sum.add(*point))
236 .scale(1.0 / ring.len() as f64);
237 let axis = apex.sub(centroid);
238 let axis = if axis.length() > 1e-9 {
239 axis.normalized()?
240 } else {
241 Vec3::new(0.0, 0.0, 1.0)
242 };
243 os_debug!(
244 "apex-cap carrier for src face {} at ({:.3},{:.3},{:.3}) r={:.3}",
245 carriers[index].source_face_id,
246 apex.x,
247 apex.y,
248 apex.z,
249 distance.abs(),
250 );
251 apex_cap_sources.insert(index);
252 apex_cap_sources.insert(carriers.len());
253 apex_cap_pairs.push((index, carriers.len()));
254 carriers.push(Carrier {
255 solid: crate::make_sphere_brep(apex, distance.abs(), axis)?,
256 source_face_id: carriers[index].source_face_id,
257 kind: OffsetFaceRole::Offset,
258 });
259 }
260 }
261 }
262 for face_id in opening_face_ids {
263 let source_face = source_faces
264 .iter()
265 .find(|face| face.id == *face_id)
266 .unwrap();
267 carriers.push(Carrier {
268 solid: if distance < 0.0 && source_face.surface.is_affine()? {
269 let mut wall = carrier_solid(source, *face_id, 0.0, distance.abs())?;
270 drop_wall_interior_loops(&mut wall)?;
285 wall
286 } else {
287 standalone_face(source, *face_id)?
288 },
289 source_face_id: *face_id,
290 kind: OffsetFaceRole::Wall,
291 });
292 }
293 if carriers.len() >= SOURCE_OPERAND as usize {
294 return Err("offset_shell: too many carrier faces for current ABI".into());
295 }
296 let scale = crate::solid_scale(source);
304 crate::report_scale_migration("offset_shell", scale, || {
305 source
306 .vertices
307 .iter()
308 .map(|vertex| vertex.point.length())
309 .fold(1.0, f64::max)
310 });
311 let smooth = synchronize_smooth_offset_boundaries(&mut carriers, &source_faces, scale)?;
312 let reflex_rebuilt = rebuild_reflex_rim_carriers(
316 &mut carriers,
317 source,
318 &source_faces,
319 &smooth.pairs,
320 scale,
321 distance,
322 )?;
323 *reflex_rebuilds = reflex_rebuilt.len();
324 if extend_oblique_carriers {
325 let oblique_extended = extend_offset_carriers_past_open_hole_rims(
326 &mut carriers,
327 source,
328 &source_faces,
329 &opening_set,
330 &smooth.pairs,
331 scale,
332 )?;
333 let mut already_extended = oblique_extended.clone();
334 already_extended.extend(reflex_rebuilt.iter().copied());
335 let fallshort_extended = extend_fallshort_curved_carriers(
336 &mut carriers,
337 source,
338 &source_faces,
339 &opening_set,
340 &smooth.pairs,
341 &already_extended,
342 scale,
343 distance,
344 )?;
345 *extension_fired = !oblique_extended.is_empty() || fallshort_extended > 0;
346 os_debug!(
347 "extended {} oblique + {fallshort_extended} fall-short carriers past their opening planes",
348 oblique_extended.len(),
349 );
350 }
351 let tolerance = 1e-7f64.max(scale * 1e-9);
352 let pair_tolerance = (scale * 1e-8).max(2e-6);
353 let reach = distance.abs() * 4.0 + pair_tolerance;
354 let carrier_samples = carriers
355 .iter()
356 .map(|carrier| carrier_extent_points(&carrier.solid))
357 .collect::<Result<Vec<_>, _>>()?;
358 let carrier_bounds = carrier_samples
359 .iter()
360 .map(|samples| Bounds::from_points(samples))
361 .collect::<Option<Vec<_>>>()
362 .ok_or_else(|| "offset_shell: carrier has no boundary samples".to_string())?;
363 let source_by_id = source_faces
364 .iter()
365 .map(|face| (face.id, *face))
366 .collect::<HashMap<_, _>>();
367 let source_samples = source_faces
368 .iter()
369 .map(|face| {
370 let standalone = standalone_face(source, face.id)?;
371 Ok((face.id, edge_sample_points(&standalone)?))
372 })
373 .collect::<Result<HashMap<_, _>, String>>()?;
374 if debug_enabled() {
375 for (index, carrier) in carriers.iter().enumerate() {
376 let face = &carrier.solid.shells[0].faces[0];
377 let samples = &carrier_samples[index];
378 let bounds =
379 samples
380 .iter()
381 .fold((Vec3::default(), Vec3::default()), |(low, high), point| {
382 (
383 Vec3 {
384 x: low.x.min(point.x),
385 y: low.y.min(point.y),
386 z: low.z.min(point.z),
387 },
388 Vec3 {
389 x: high.x.max(point.x),
390 y: high.y.max(point.y),
391 z: high.z.max(point.z),
392 },
393 )
394 });
395 let surface = &face.surface;
396 let rational = surface
397 .control_points
398 .iter()
399 .flatten()
400 .any(|point| (point.w - 1.0).abs() > 1e-12);
401 os_debug!(
402 "carrier[{index}] kind={:?} source_face={} loops={} deg=({},{}) net={}x{} rational={rational} bounds=({:.3},{:.3},{:.3})..({:.3},{:.3},{:.3})",
403 carrier.kind, carrier.source_face_id, face.loops.len(),
404 surface.degree_u, surface.degree_v,
405 surface.control_points.len(),
406 surface.control_points.first().map(|row| row.len()).unwrap_or(0),
407 bounds.0.x, bounds.0.y, bounds.0.z, bounds.1.x, bounds.1.y, bounds.1.z,
408 );
409 }
410 for (index, carrier) in carriers.iter().enumerate() {
411 if !matches!(carrier.kind, OffsetFaceRole::Offset) {
412 continue;
413 }
414 let source_face = source_by_id[&carrier.source_face_id];
415 let carrier_surface = &carrier.solid.shells[0].faces[0].surface;
416 let (ku, kv) = (
417 crate::KnotVector::new(
418 source_face.surface.knots_u.clone(),
419 source_face.surface.degree_u,
420 ),
421 crate::KnotVector::new(
422 source_face.surface.knots_v.clone(),
423 source_face.surface.degree_v,
424 ),
425 );
426 if let (Ok(ku), Ok(kv)) = (ku, kv) {
427 let [u0, u1] = ku.domain();
428 let [v0, v1] = kv.domain();
429 let mut worst = 0.0f64;
430 let mut worst_at = (0.0f64, 0.0f64);
431 for iu in 0..=24 {
432 for iv in 0..=24 {
433 let u = u0 + (u1 - u0) * iu as f64 / 24.0;
434 let v = v0 + (v1 - v0) * iv as f64 / 24.0;
435 let expected = face_offsets(source_face)
436 .at(u, v, -distance)
437 .map(|sample| sample.point);
438 let actual = carrier_surface.evaluate(u, v);
439 if let (Ok(expected), Ok(actual)) = (expected, actual) {
440 let error = expected.sub(actual).length();
441 if error > worst {
442 worst = error;
443 worst_at = (u, v);
444 }
445 }
446 }
447 }
448 let mut normal_swing = 0.0f64;
449 let mut previous: Option<Vec3> = None;
450 for iu in 0..=48 {
451 let u = u0 + (u1 - u0) * iu as f64 / 48.0;
452 let v = (v0 + v1) / 2.0;
453 if let Ok(normal) = face_normal(source_face, u, v) {
454 if let Some(previous) = previous {
455 normal_swing = normal_swing.max(previous.sub(normal).length());
456 }
457 previous = Some(normal);
458 }
459 }
460 let weights = source_face
461 .surface
462 .control_points
463 .iter()
464 .flatten()
465 .map(|point| point.w)
466 .fold((f64::MAX, f64::MIN), |(low, high), w| {
467 (low.min(w), high.max(w))
468 });
469 os_debug!(
470 "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={:?}",
471 worst_at.0, worst_at.1,
472 source_face.surface.control_points.len(),
473 source_face.surface.control_points.first().map(|row| row.len()).unwrap_or(0),
474 source_face.surface.degree_u, source_face.surface.degree_v,
475 weights.0, weights.1,
476 &source_face.surface.knots_u,
477 );
478 }
479 }
480 os_debug!("smooth pairs: {:?}", smooth.pairs);
481 }
482 let mut imprint = empty_imprint();
483 let mut next_piece_id = 1;
484 let mut next_vertex_id = 1;
485 for first in 0..carriers.len() {
486 for second in first + 1..carriers.len() {
487 if matches!(carriers[first].kind, OffsetFaceRole::Wall)
488 && matches!(carriers[second].kind, OffsetFaceRole::Wall)
489 {
490 continue;
491 }
492 if smooth.pairs.contains(&(first, second)) {
493 os_debug!("pair ({first},{second}) skipped: smooth");
494 continue;
495 }
496 if !carrier_bounds[first].intersects(carrier_bounds[second], pair_tolerance) {
497 os_debug!("pair ({first},{second}) skipped: bounds");
498 continue;
499 }
500 let first_source = source_by_id[&carriers[first].source_face_id];
501 let second_source = source_by_id[&carriers[second].source_face_id];
502 if !source_faces_adjacent(first_source, second_source)
503 && sample_separation(
504 &source_samples[&first_source.id],
505 &source_samples[&second_source.id],
506 ) > reach
507 {
508 os_debug!("pair ({first},{second}) skipped: separation");
509 continue;
510 }
511 let pair = match build_imprints(
512 &carriers[first].solid,
513 &carriers[second].solid,
514 &ImprintOptions {
515 tolerance,
516 maximum_fit_points: 96,
517 local_fit: true,
518 fit_chunk_points: None,
519 maximum_ssi_step: Some((scale * 0.0005).max(tolerance * 100.0)),
525 },
526 ) {
527 Ok(pair) => pair,
528 Err(error) => {
536 os_debug!("pair ({first},{second}) imprint failed: {error}");
537 continue;
538 }
539 };
540 let pieces_before = imprint.pieces.len();
541 merge_pair_imprint(
542 &mut imprint,
543 pair,
544 first as u8,
545 second as u8,
546 &mut next_piece_id,
547 &mut next_vertex_id,
548 tolerance,
549 scale,
550 );
551 os_debug!(
552 "pair ({first},{second}) src=({},{}) pieces+={}",
553 carriers[first].source_face_id,
554 carriers[second].source_face_id,
555 imprint.pieces.len() - pieces_before,
556 );
557 }
558 }
559 if distance < 0.0 {
560 for index in 0..carriers.len() {
571 if !matches!(carriers[index].kind, OffsetFaceRole::Wall) {
572 continue;
573 }
574 let opening = source_by_id[&carriers[index].source_face_id];
575 for retained_id in &retained {
576 let retained_face = source_by_id[retained_id];
577 if !source_faces_adjacent(opening, retained_face) {
578 continue;
579 }
580 let neighbor = standalone_face(source, *retained_id)?;
581 let pair = match build_imprints(
582 &carriers[index].solid,
583 &neighbor,
584 &ImprintOptions {
585 tolerance,
586 maximum_fit_points: 96,
587 local_fit: true,
588 fit_chunk_points: None,
589 maximum_ssi_step: Some((scale * 0.0005).max(tolerance * 100.0)),
590 },
591 ) {
592 Ok(pair) => pair,
593 Err(error) => {
594 os_debug!(
595 "wall×source pair ({index},{retained_id}) imprint failed: {error}"
596 );
597 continue;
598 }
599 };
600 let pieces_before = imprint.pieces.len();
601 merge_pair_imprint(
602 &mut imprint,
603 pair,
604 index as u8,
605 SOURCE_OPERAND,
606 &mut next_piece_id,
607 &mut next_vertex_id,
608 tolerance,
609 scale,
610 );
611 os_debug!(
612 "wall×source pair ({index},{retained_id}) pieces+={}",
613 imprint.pieces.len() - pieces_before,
614 );
615 }
616 }
617 }
618 for (cone_index, cap_index) in &apex_cap_pairs {
625 let cap_face_id = carriers[*cap_index].solid.shells[0].faces[0].id;
626 let piece_ids = imprint
627 .by_face
628 .iter()
629 .find(|entry| entry.operand == *cap_index as u8 && entry.face_id == cap_face_id)
630 .map(|entry| entry.piece_ids.clone())
631 .unwrap_or_default();
632 let coincidence_band = 2e-5f64.max(scale * 1e-7);
633 for piece_id in piece_ids {
634 let Some((curve, t0, t1)) = imprint
635 .pieces
636 .iter()
637 .find(|piece| piece.id == piece_id)
638 .map(|piece| (piece.curve.clone(), piece.t0, piece.t1))
639 else {
640 continue;
641 };
642 for endpoint in [curve.evaluate(t0)?, curve.evaluate(t1)?] {
643 for edge in &carriers[*cone_index].solid.edges {
644 if edge.degenerate {
645 continue;
646 }
647 let projection = crate::project_point_to_curve(&edge.curve, endpoint)?;
648 if projection.distance > coincidence_band
649 || projection.u < edge.t0 + 1e-9
650 || projection.u > edge.t1 - 1e-9
651 {
652 continue;
653 }
654 let parameter = projection.u;
655 if let Some(existing) = imprint.edge_splits.iter_mut().find(|split| {
656 split.operand == *cone_index as u8 && split.edge_id == edge.id
657 }) {
658 if !existing
659 .parameters
660 .iter()
661 .any(|value| (*value - parameter).abs() <= 1e-8)
662 {
663 existing.parameters.push(parameter);
664 }
665 } else {
666 imprint.edge_splits.push(EdgeSplitRecord {
667 operand: *cone_index as u8,
668 edge_id: edge.id,
669 parameters: vec![parameter],
670 });
671 }
672 os_debug!(
673 "apex-cap ring split: cone carrier {cone_index} edge {} at t={parameter:.6}",
674 edge.id,
675 );
676 }
677 }
678 }
679 }
680 if debug_enabled() {
681 for piece in &imprint.pieces {
682 let start = piece.curve.evaluate(piece.t0);
683 let mid = piece.curve.evaluate((piece.t0 + piece.t1) * 0.5);
684 let end = piece.curve.evaluate(piece.t1);
685 if let (Ok(start), Ok(mid), Ok(end)) = (start, mid, end) {
686 os_debug!(
687 "piece[{}] ({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})",
688 piece.id,
689 start.x,
690 start.y,
691 start.z,
692 mid.x,
693 mid.y,
694 mid.z,
695 end.x,
696 end.y,
697 end.z,
698 );
699 }
700 }
701 }
702 synchronize_smooth_edge_splits(&mut imprint, &smooth.edge_pairs);
703 let boundary_coincidence_tolerance = 2e-5f64.max(scale * 1e-7);
708 let piece_geometry = imprint
709 .pieces
710 .iter()
711 .map(|piece| {
712 (
713 piece.id,
714 FragmentEdgeGeometry {
715 curve: piece.curve.clone(),
716 t0: piece.t0,
717 t1: piece.t1,
718 },
719 )
720 })
721 .collect::<HashMap<_, _>>();
722 for (index, carrier) in carriers.iter().enumerate() {
723 if !matches!(carrier.kind, OffsetFaceRole::Offset) {
724 continue;
725 }
726 let boundaries = carrier
727 .solid
728 .edges
729 .iter()
730 .map(|edge| FragmentEdgeGeometry {
731 curve: edge.curve.clone(),
732 t0: edge.t0,
733 t1: edge.t1,
734 })
735 .collect::<Vec<_>>();
736 if let Some(by_face) = imprint
737 .by_face
738 .iter_mut()
739 .find(|entry| entry.operand == index as u8)
740 {
741 let before = by_face.piece_ids.clone();
742 by_face.piece_ids.retain(|piece_id| {
743 let piece = &piece_geometry[piece_id];
744 !boundaries.iter().any(|boundary| {
745 fragment_edge_lies_on(piece, boundary, boundary_coincidence_tolerance)
746 .unwrap_or(false)
747 })
748 });
749 if debug_enabled() && before.len() != by_face.piece_ids.len() {
750 os_debug!(
751 "carrier[{index}] dropped boundary-coincident pieces: {:?} -> {:?}",
752 before,
753 by_face.piece_ids,
754 );
755 }
756 }
757 }
758
759 if debug_enabled() {
760 for entry in &imprint.by_face {
761 os_debug!(
762 "by_face operand={} face={} pieces={:?}",
763 entry.operand,
764 entry.face_id,
765 entry.piece_ids,
766 );
767 }
768 }
769 let mut split_carriers = Vec::new();
770 let mut fragments_by_carrier = Vec::new();
771 for (index, carrier) in carriers.iter().enumerate() {
772 let split = if matches!(carrier.kind, OffsetFaceRole::Offset)
779 && !apex_cap_sources.contains(&index)
780 {
781 carrier.solid.clone()
782 } else {
783 apply_edge_splits(&carrier.solid, index as u8, &imprint)?
784 };
785 let fragments = fragment_solid(&split, index as u8, &imprint)?;
786 os_debug!(
787 "carrier[{index}] kind={:?} src={} fragments={}",
788 carrier.kind,
789 carrier.source_face_id,
790 fragments.len(),
791 );
792 if debug_enabled() {
793 for (fragment_index, fragment) in fragments.iter().enumerate() {
794 let mut boundary_count = 0usize;
795 let mut imprint_pieces = Vec::new();
796 let mut derived_count = 0usize;
797 for coedge in fragment
798 .loops
799 .iter()
800 .flat_map(|loop_record| &loop_record.coedges)
801 {
802 match &coedge.source {
803 FragmentEdgeSource::Boundary { .. }
804 | FragmentEdgeSource::SharedBoundary { .. } => boundary_count += 1,
805 FragmentEdgeSource::Imprint { piece_id } => imprint_pieces.push(*piece_id),
806 FragmentEdgeSource::Derived { .. } => derived_count += 1,
807 }
808 }
809 os_debug!(
810 " frag[{index}.{fragment_index}] test=({:.3},{:.3},{:.3}) uv=({:.3},{:.3}) boundary={boundary_count} derived={derived_count} pieces={imprint_pieces:?}",
811 fragment.test_point.x, fragment.test_point.y, fragment.test_point.z,
812 fragment.test_uv.x, fragment.test_uv.y,
813 );
814 }
815 }
816 split_carriers.push(split);
817 fragments_by_carrier.push(fragments);
818 }
819 let mut assembly_sources = [(SOURCE_OPERAND, source)]
820 .into_iter()
821 .collect::<HashMap<_, _>>();
822 for (index, carrier) in split_carriers.iter().enumerate() {
823 assembly_sources.insert(index as u8, carrier);
824 }
825
826 let source_edge_by_id = source
827 .edges
828 .iter()
829 .map(|edge| (edge.id, edge))
830 .collect::<HashMap<_, _>>();
831 let retained_faces_with_edges = retained
832 .iter()
833 .map(|face_id| {
834 let face = source_by_id[face_id];
835 let edges = face
836 .loops
837 .iter()
838 .flat_map(|loop_record| &loop_record.coedges)
839 .filter_map(|coedge| source_edge_by_id.get(&coedge.edge_id).copied())
840 .collect::<Vec<_>>();
841 (face, edges)
842 })
843 .collect::<Vec<_>>();
844 let offset_skin_tolerance = 2e-3f64.max(scale * 5e-5).max(distance.abs() * 1e-3);
845
846 let mut chosen_offsets = Vec::new();
847 let mut chosen_walls = Vec::new();
848 for (index, carrier) in carriers.iter().enumerate() {
849 let seed = source_seed(source, carrier.source_face_id)?;
850 if matches!(carrier.kind, OffsetFaceRole::Offset) {
851 let on_skin = fragments_by_carrier[index]
857 .iter()
858 .map(|fragment| {
859 point_on_offset_skin(
860 fragment.test_point,
861 &retained_faces_with_edges,
862 distance,
863 offset_skin_tolerance,
864 )
865 })
866 .collect::<Result<Vec<_>, String>>()?;
867 let skin_filter_active = on_skin.iter().any(|flag| *flag);
868 let mut classified = Vec::new();
869 let mut viable = Vec::new();
870 for (fragment_index, fragment) in fragments_by_carrier[index].iter().enumerate() {
871 let class = classify_point(fragment.test_point, source, tolerance * 10.0)?.class;
872 let excluded_by_class = (distance > 0.0 && class == PointClass::Out)
873 || (distance < 0.0 && class == PointClass::In);
874 let contact = if excluded_by_class {
875 false
876 } else {
877 distance > 0.0
878 && fragment_has_sustained_source_contact(
879 fragment,
880 &source_faces,
881 &assembly_sources,
882 &imprint,
883 2e-3f64.max(scale * 5e-5),
884 )?
885 };
886 os_debug!(
887 " select[{index}.{fragment_index}] class={class:?} contact={contact} on_skin={} seed_in={:?} uv=({:.3},{:.3})",
888 on_skin[fragment_index],
889 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8),
890 fragment.test_uv.x, fragment.test_uv.y,
891 );
892 if excluded_by_class || (skin_filter_active && !on_skin[fragment_index]) {
893 continue;
894 }
895 classified.push(fragment.clone());
896 if contact {
897 continue;
898 }
899 viable.push(fragment.clone());
900 }
901 os_debug!(" select[{index}] seed=({:.4},{:.4})", seed.x, seed.y);
902 let seeded = viable
903 .iter()
904 .filter(|fragment| {
905 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)
906 .is_ok_and(|class| class != PolygonClass::Outside)
907 })
908 .cloned()
909 .collect::<Vec<_>>();
910 let seeded_classified = classified
911 .iter()
912 .filter(|fragment| {
913 parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)
914 .is_ok_and(|class| class != PolygonClass::Outside)
915 })
916 .cloned()
917 .collect::<Vec<_>>();
918 let selected = if viable.is_empty() {
919 if !skin_filter_active {
920 None
930 } else {
931 let mut by_area = classified
932 .into_iter()
933 .map(|fragment| {
934 let area = parameter_space_area(&fragment_as_trim(&fragment))?.abs();
935 Ok((fragment, area))
936 })
937 .collect::<Result<Vec<_>, String>>()?;
938 by_area.sort_by(|a, b| b.1.total_cmp(&a.1));
939 by_area.into_iter().next().map(|entry| entry.0)
940 }
941 } else if !seeded_classified.is_empty() {
942 let mut candidates = seeded_classified;
943 candidates.sort_by(|a, b| {
944 a.test_uv
945 .sub(seed)
946 .length()
947 .total_cmp(&b.test_uv.sub(seed).length())
948 });
949 candidates.into_iter().next()
950 } else if viable.len() > 2 {
951 let mut by_area = viable
952 .into_iter()
953 .map(|fragment| {
954 let area = parameter_space_area(&fragment_as_trim(&fragment))?.abs();
955 Ok((fragment, area))
956 })
957 .collect::<Result<Vec<_>, String>>()?;
958 by_area.sort_by(|a, b| b.1.total_cmp(&a.1));
959 by_area.into_iter().next().map(|entry| entry.0)
960 } else {
961 let mut candidates = if seeded.is_empty() { viable } else { seeded };
962 candidates.sort_by(|a, b| {
963 a.test_uv
964 .sub(seed)
965 .length()
966 .total_cmp(&b.test_uv.sub(seed).length())
967 });
968 candidates.into_iter().next()
969 };
970 if let Some(fragment) = selected {
971 chosen_offsets.push(fragment);
972 }
973 } else {
974 let opening = source_by_id[&carrier.source_face_id];
978 let wall_seeds = opening_wall_seeds(
979 opening,
980 &split_carriers[index].shells[0].faces[0],
981 source,
982 &opening_set,
983 distance,
984 )?;
985 let carrier_planar =
993 surface_is_planar(&split_carriers[index].shells[0].faces[0].surface, tolerance)
994 .unwrap_or(false);
995 let mut selected_indices = Vec::new();
996 for (fragment_index, fragment) in fragments_by_carrier[index].iter().enumerate() {
997 if parameter_point_in_face(&fragment_as_trim(fragment), seed, 1e-8)?
998 == PolygonClass::Outside
999 {
1000 if point_on_offset_skin(
1013 fragment.test_point,
1014 &retained_faces_with_edges,
1015 distance,
1016 offset_skin_tolerance,
1017 )? && !(carrier_planar
1018 && wall_seeds.iter().any(|wall_seed| {
1019 parameter_point_in_face(&fragment_as_trim(fragment), *wall_seed, 1e-8)
1020 .is_ok_and(|class| class != PolygonClass::Outside)
1021 }))
1022 {
1023 os_debug!(
1024 " wall[{index}.{fragment_index}] skipped: void cross-section at \
1025 ({:.3},{:.3},{:.3})",
1026 fragment.test_point.x,
1027 fragment.test_point.y,
1028 fragment.test_point.z,
1029 );
1030 continue;
1031 }
1032 selected_indices.push(fragment_index);
1033 }
1034 }
1035 for wall_seed in wall_seeds {
1036 let fragments = &fragments_by_carrier[index];
1037 let nearest_containing = fragments
1038 .iter()
1039 .enumerate()
1040 .filter(|(_, fragment)| {
1041 parameter_point_in_face(&fragment_as_trim(fragment), wall_seed, 1e-8)
1042 .is_ok_and(|class| class != PolygonClass::Outside)
1043 })
1044 .min_by(|(_, first), (_, second)| {
1045 first
1046 .test_uv
1047 .sub(wall_seed)
1048 .length()
1049 .total_cmp(&second.test_uv.sub(wall_seed).length())
1050 })
1051 .map(|(fragment_index, _)| fragment_index);
1052 let nearest = if nearest_containing.is_some() {
1057 nearest_containing
1058 } else {
1059 let seed_point = split_carriers[index].shells[0].faces[0]
1060 .surface
1061 .evaluate(wall_seed.x, wall_seed.y)?;
1062 fragments
1063 .iter()
1064 .enumerate()
1065 .min_by(|(_, first), (_, second)| {
1066 first
1067 .test_point
1068 .sub(seed_point)
1069 .length()
1070 .total_cmp(&second.test_point.sub(seed_point).length())
1071 })
1072 .map(|(fragment_index, _)| fragment_index)
1073 };
1074 if let Some(fragment_index) = nearest {
1075 let candidate = &fragments_by_carrier[index][fragment_index];
1076 if (nearest_containing.is_none() || !carrier_planar)
1085 && point_on_offset_skin(
1086 candidate.test_point,
1087 &retained_faces_with_edges,
1088 distance,
1089 offset_skin_tolerance,
1090 )?
1091 {
1092 os_debug!(
1093 " wall[{index}.{fragment_index}] seed-hit skipped: void \
1094 cross-section (planar={carrier_planar}) at ({:.3},{:.3},{:.3})",
1095 candidate.test_point.x,
1096 candidate.test_point.y,
1097 candidate.test_point.z,
1098 );
1099 continue;
1100 }
1101 let existing = chosen_offsets.iter().cloned().chain(
1102 selected_indices
1103 .iter()
1104 .map(|selected| fragments_by_carrier[index][*selected].clone()),
1105 );
1106 if !selected_indices.contains(&fragment_index)
1107 && !would_overuse_existing_boundary(
1108 candidate,
1109 existing,
1110 &assembly_sources,
1111 &imprint,
1112 2e-3,
1113 )?
1114 {
1115 selected_indices.push(fragment_index);
1116 }
1117 }
1118 }
1119 for fragment_index in selected_indices.iter().copied() {
1120 chosen_walls.push(fragments_by_carrier[index][fragment_index].clone());
1121 }
1122 }
1123 }
1124 if debug_enabled() {
1125 for fragment in &chosen_offsets {
1126 os_debug!(
1127 "chosen offset: operand={} src={} test=({:.3},{:.3},{:.3})",
1128 fragment.operand,
1129 carriers[fragment.operand as usize].source_face_id,
1130 fragment.test_point.x,
1131 fragment.test_point.y,
1132 fragment.test_point.z,
1133 );
1134 for (loop_index, loop_record) in fragment.loops.iter().enumerate() {
1135 for coedge in &loop_record.coedges {
1136 let surface = &fragment.surface;
1137 let [c0, c1] = coedge.pcurve.domain().unwrap_or([0.0, 0.0]);
1138 let uv0 = coedge.pcurve.evaluate(c0);
1139 let uv1 = coedge.pcurve.evaluate(c1);
1140 if let (Ok(uv0), Ok(uv1)) = (uv0, uv1) {
1141 let p0 = surface.evaluate(uv0.x, uv0.y);
1142 let p1 = surface.evaluate(uv1.x, uv1.y);
1143 if let (Ok(p0), Ok(p1)) = (p0, p1) {
1144 let kind = match &coedge.source {
1145 FragmentEdgeSource::Boundary { edge_id, .. } => {
1146 format!("bnd:{edge_id}")
1147 }
1148 FragmentEdgeSource::SharedBoundary { edge_id, .. } => {
1149 format!("sbnd:{edge_id}")
1150 }
1151 FragmentEdgeSource::Imprint { piece_id } => {
1152 format!("imp:{piece_id}")
1153 }
1154 FragmentEdgeSource::Derived { .. } => "derived".to_string(),
1155 };
1156 os_debug!(
1157 " loop{loop_index} {kind} ({:.4},{:.4},{:.4})..({:.4},{:.4},{:.4})",
1158 p0.x, p0.y, p0.z, p1.x, p1.y, p1.z,
1159 );
1160 }
1161 }
1162 }
1163 }
1164 }
1165 for fragment in &chosen_walls {
1166 os_debug!(
1167 "chosen wall: operand={} src={} test=({:.3},{:.3},{:.3})",
1168 fragment.operand,
1169 carriers[fragment.operand as usize].source_face_id,
1170 fragment.test_point.x,
1171 fragment.test_point.y,
1172 fragment.test_point.z,
1173 );
1174 }
1175 }
1176 if chosen_offsets.is_empty() {
1177 return Err("offset_shell: carrier intersections produced no offset boundary".into());
1178 }
1179 let mut boundary_use_count = HashMap::<[i64; 9], usize>::default();
1183 for fragment in &chosen_offsets {
1184 for coedge in fragment
1185 .loops
1186 .iter()
1187 .flat_map(|loop_record| &loop_record.coedges)
1188 {
1189 let key = fragment_edge_segment_key(&coedge.source, &assembly_sources, &imprint)?;
1190 *boundary_use_count.entry(key).or_default() += 1;
1191 }
1192 }
1193 let mut manifold_walls = Vec::new();
1194 for fragment in chosen_walls {
1195 let keys = fragment
1196 .loops
1197 .iter()
1198 .flat_map(|loop_record| &loop_record.coedges)
1199 .map(|coedge| fragment_edge_segment_key(&coedge.source, &assembly_sources, &imprint))
1200 .collect::<Result<Vec<_>, _>>()?;
1201 if !keys
1202 .iter()
1203 .any(|key| boundary_use_count.get(key).copied().unwrap_or(0) >= 2)
1204 {
1205 for key in keys {
1206 *boundary_use_count.entry(key).or_default() += 1;
1207 }
1208 manifold_walls.push(fragment);
1209 }
1210 }
1211 os_debug!(
1212 "manifold walls kept: {} operands={:?}",
1213 manifold_walls.len(),
1214 manifold_walls
1215 .iter()
1216 .map(|fragment| fragment.operand)
1217 .collect::<Vec<_>>(),
1218 );
1219 let chosen_walls = manifold_walls;
1220
1221 let source_fragments = fragment_solid(source, SOURCE_OPERAND, &empty_imprint())?;
1222 let mut selected = source_fragments
1223 .into_iter()
1224 .filter(|fragment| retained.contains(&fragment.source_face_id))
1225 .collect::<Vec<_>>();
1226 let mut face_images = selected
1227 .iter()
1228 .map(|fragment| OffsetShellFaceImageRecord {
1229 role: OffsetFaceRole::Source,
1230 source_face_id: fragment.source_face_id,
1231 })
1232 .collect::<Vec<_>>();
1233 if distance < 0.0 {
1234 for fragment in &mut selected {
1235 flip_fragment(fragment)?;
1236 }
1237 }
1238 if distance > 0.0 {
1239 for fragment in &mut chosen_offsets {
1240 flip_fragment(fragment)?;
1241 }
1242 }
1243 face_images.extend(
1244 chosen_offsets
1245 .iter()
1246 .map(|fragment| OffsetShellFaceImageRecord {
1247 role: OffsetFaceRole::Offset,
1248 source_face_id: carriers[fragment.operand as usize].source_face_id,
1249 }),
1250 );
1251 face_images.extend(
1252 chosen_walls
1253 .iter()
1254 .map(|fragment| OffsetShellFaceImageRecord {
1255 role: OffsetFaceRole::Wall,
1256 source_face_id: carriers[fragment.operand as usize].source_face_id,
1257 }),
1258 );
1259 selected.extend(chosen_offsets);
1260 selected.extend(chosen_walls);
1261 let mut solid = assemble_open_fragments(
1262 selected,
1263 &assembly_sources,
1264 &imprint,
1265 2e-3f64.max(scale * 5e-5),
1266 )?;
1267 orient_open_solid_faces(&mut solid)?;
1268 let connector_images = complete_sharp_offset_connectors(
1269 &mut solid,
1270 source,
1271 &face_images,
1272 distance,
1273 2e-3f64.max(scale * 5e-5),
1274 )?;
1275 os_debug!(
1276 "sharp connector completion added {} faces",
1277 connector_images.len()
1278 );
1279 if !connector_images.is_empty() {
1280 for shell in &mut solid.shells {
1285 shell.faces.retain(|face| {
1286 !matches!(
1287 face_images
1288 .get(face.id.saturating_sub(1) as usize)
1289 .map(|image| image.role),
1290 Some(OffsetFaceRole::Wall)
1291 )
1292 });
1293 }
1294 solid.shells.retain(|shell| !shell.faces.is_empty());
1295 }
1296 face_images.extend(connector_images);
1297 orient_open_solid_faces(&mut solid)?;
1298 let welded_rims = weld_coplanar_orphan_rims(&mut solid, 2e-3f64.max(scale * 5e-5))?;
1302 os_debug!("welded {welded_rims} coplanar orphan rims into opening caps");
1303 let ruled_rims = weld_ruled_offset_rims(&mut solid, 2e-3f64.max(scale * 5e-5))?;
1306 os_debug!("welded {ruled_rims} non-coplanar offset rims into ruled bands");
1307 let pair_rims =
1310 weld_coplanar_rim_pair_annuli(&mut solid, &mut face_images, 2e-3f64.max(scale * 5e-5))?;
1311 os_debug!("welded {pair_rims} coplanar rim pairs into annulus walls");
1312 if debug_enabled() {
1313 for (shell_index, shell) in solid.shells.iter().enumerate() {
1314 for face in &shell.faces {
1315 os_debug!(
1316 "POSTWELD shell {} face {} same_sense={} loops={}",
1317 shell_index,
1318 face.id,
1319 face.same_sense,
1320 face.loops.len()
1321 );
1322 for (loop_index, loop_record) in face.loops.iter().enumerate() {
1323 let walk = loop_record
1324 .coedges
1325 .iter()
1326 .map(|coedge| {
1327 let spin = (|| -> Result<f64, String> {
1328 let [d0, d1] = coedge.pcurve.domain()?;
1329 let a = coedge.pcurve.evaluate(d0 + (d1 - d0) * 0.45)?;
1330 let b = coedge.pcurve.evaluate(d0 + (d1 - d0) * 0.55)?;
1331 let pa = face.surface.evaluate(a.x, a.y)?;
1332 let pb = face.surface.evaluate(b.x, b.y)?;
1333 Ok(pa.cross(pb).z)
1334 })()
1335 .unwrap_or(f64::NAN);
1336 format!(
1337 "{}{}(spin{:+.0})",
1338 if coedge.forward { "+" } else { "-" },
1339 coedge.edge_id,
1340 spin.signum()
1341 )
1342 })
1343 .collect::<Vec<_>>();
1344 os_debug!(" loop {} {:?}", loop_index, walk);
1345 }
1346 }
1347 }
1348 }
1349 let completion_carriers = carriers.iter().collect::<Vec<_>>();
1350 let completion_tolerance = 2e-3f64.max(scale * 5e-5);
1351 let duplicate_welds = weld_duplicate_one_use_arcs(&mut solid, completion_tolerance)?;
1355 os_debug!("welded {duplicate_welds} duplicate one-use boundary arcs");
1356 if debug_enabled() {
1357 let mut use_counts = HashMap::<u64, usize>::default();
1358 for coedge in solid
1359 .shells
1360 .iter()
1361 .flat_map(|shell| &shell.faces)
1362 .flat_map(|face| &face.loops)
1363 .flat_map(|loop_record| &loop_record.coedges)
1364 {
1365 *use_counts.entry(coedge.edge_id).or_default() += 1;
1366 }
1367 let open = use_counts.values().filter(|count| **count == 1).count();
1368 os_debug!(
1369 "assembled: faces={} open_edges={open}",
1370 solid
1371 .shells
1372 .iter()
1373 .map(|shell| shell.faces.len())
1374 .sum::<usize>()
1375 );
1376 }
1377 let completed_images = complete_opening_boundary_cycles(
1378 &mut solid,
1379 &completion_carriers,
1380 &face_images,
1381 completion_tolerance,
1382 )?;
1383 os_debug!("completion added {} faces", completed_images.len());
1384 face_images.extend(completed_images);
1385 let solid = merge_same_surface_faces_open(&solid, (1e-7f64).max(scale * 1e-9))?;
1386 let assembly_tolerance = 2e-3f64.max(scale * 5e-5);
1387 let mut solid = finalize_assembled_solid(solid, assembly_tolerance)?;
1391 if ruled_rims + pair_rims > 0 {
1392 coheres_face_normals(&mut solid)?;
1397 }
1398 let solid = solid;
1399 face_images = solid
1400 .shells
1401 .iter()
1402 .flat_map(|shell| &shell.faces)
1403 .map(|face| {
1404 face_images
1405 .get(face.id.saturating_sub(1) as usize)
1406 .cloned()
1407 .ok_or_else(|| "offset_shell: merged face lost provenance".to_string())
1408 })
1409 .collect::<Result<Vec<_>, _>>()?;
1410 let mut rim_use_counts = HashMap::<u64, usize>::default();
1417 for coedge in solid
1418 .shells
1419 .iter()
1420 .flat_map(|shell| &shell.faces)
1421 .flat_map(|face| &face.loops)
1422 .flat_map(|loop_record| &loop_record.coedges)
1423 {
1424 *rim_use_counts.entry(coedge.edge_id).or_default() += 1;
1425 }
1426 if debug_enabled() {
1427 for (shell_index, shell) in solid.shells.iter().enumerate() {
1428 for face in &shell.faces {
1429 let role = face_images
1430 .get(face.id.saturating_sub(1) as usize)
1431 .map(|image| image.role);
1432 let c = face.surface.evaluate(0.5, 0.5).unwrap_or_default();
1433 os_debug!(
1434 "DUMPFACE shell {} face {} affine={} role={:?} loops={} center=({:.3},{:.3},{:.3})",
1435 shell_index, face.id, face.surface.is_affine().unwrap_or(false), role,
1436 face.loops.len(), c.x, c.y, c.z
1437 );
1438 for (li, lp) in face.loops.iter().enumerate() {
1439 let ids = lp.coedges.iter().map(|c| c.edge_id).collect::<Vec<_>>();
1440 os_debug!(" loop {} edges {:?}", li, ids);
1441 }
1442 }
1443 }
1444 for edge in &solid.edges {
1445 let uses = rim_use_counts.get(&edge.id).copied().unwrap_or(0);
1446 let s = edge.curve.evaluate(edge.t0)?;
1447 let m = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
1448 let q = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25)?;
1449 os_debug!(
1450 "DUMPEDGE edge {} uses={} closed={} deg={} cpts={} cdeg={} knots={:?} start=({:.3},{:.3},{:.3}) q=({:.3},{:.3},{:.3}) mid=({:.3},{:.3},{:.3})",
1451 edge.id, uses, edge.start_vertex_id == edge.end_vertex_id, edge.degenerate,
1452 edge.curve.control_points.len(), edge.curve.degree, edge.curve.knots,
1453 s.x, s.y, s.z, q.x, q.y, q.z, m.x, m.y, m.z
1454 );
1455 }
1456 }
1457 for edge in &solid.edges {
1458 if rim_use_counts.get(&edge.id).copied().unwrap_or(0) != 1
1459 || edge.start_vertex_id != edge.end_vertex_id
1460 {
1461 continue;
1462 }
1463 let anchor = edge.curve.evaluate(edge.t0)?;
1464 let sweep_threshold = 1e-4f64.max(scale * 1e-6);
1465 let sweeps = [0.25, 0.5, 0.75].into_iter().any(|fraction| {
1466 edge.curve
1467 .evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction)
1468 .map(|point| point.sub(anchor).length() > sweep_threshold)
1469 .unwrap_or(false)
1470 });
1471 if sweeps {
1472 if debug_enabled() {
1473 let rim_mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
1474 os_debug!(
1475 "PROBE orphan rim edge {} anchor=({:.4},{:.4},{:.4}) mid=({:.4},{:.4},{:.4})",
1476 edge.id,
1477 anchor.x,
1478 anchor.y,
1479 anchor.z,
1480 rim_mid.x,
1481 rim_mid.y,
1482 rim_mid.z
1483 );
1484 for (shell_index, shell) in solid.shells.iter().enumerate() {
1485 for face in &shell.faces {
1486 let uses_rim = face
1487 .loops
1488 .iter()
1489 .flat_map(|l| &l.coedges)
1490 .any(|c| c.edge_id == edge.id);
1491 let affine = face.surface.is_affine().unwrap_or(false);
1492 let on = edge_on_surface(edge, &face.surface, 2e-3f64.max(scale * 5e-5))
1493 .unwrap_or(false);
1494 let proj = project_point_to_surface(&face.surface, rim_mid)?;
1495 let contains = if on {
1496 parameter_point_in_face(
1497 face,
1498 Vec2 {
1499 x: proj.u,
1500 y: proj.v,
1501 },
1502 2e-3f64.max(scale * 5e-5),
1503 )? != PolygonClass::Outside
1504 } else {
1505 false
1506 };
1507 if on || uses_rim {
1508 os_debug!(
1509 " face {} shell {} affine={} uses_rim={} on_surf={} proj_dist={:.5} contains={}",
1510 face.id, shell_index, affine, uses_rim, on, proj.distance, contains
1511 );
1512 }
1513 }
1514 }
1515 }
1516 return Err(format!(
1517 "offset_shell: unwelded rim leaves a non-watertight shell \
1518 (one-use closed edge {} near ({:.3},{:.3},{:.3})); this \
1519 curved-solid opening is not yet supported",
1520 edge.id, anchor.x, anchor.y, anchor.z
1521 ));
1522 }
1523 }
1524 Ok(OffsetShellResultRecord { solid, face_images })
1525}
1526
1527pub fn offset_shell_with_diagnostics(
1530 source: &BrepSolid,
1531 opening_face_ids: &[u64],
1532 distance: f64,
1533 tolerances: Option<KernelTolerances>,
1534) -> Result<KernelOutcome<OffsetShellResultRecord>, String> {
1535 let policy = tolerances.unwrap_or_else(|| KernelTolerances::for_solid(source, 1e-7));
1536 policy.check()?;
1537 let mut diagnostics = KernelDiagnostics::default();
1538 diagnostics.count_n(
1539 "collect.source_faces",
1540 source
1541 .shells
1542 .iter()
1543 .map(|shell| shell.faces.len() as u64)
1544 .sum(),
1545 );
1546 diagnostics.count_n("collect.opening_faces", opening_face_ids.len() as u64);
1547 diagnostics.measure_max("offset.distance", distance.abs());
1548 diagnostics.measure_max("tolerance.model", policy.model);
1549 let result = offset_shell(source, opening_face_ids, distance)?;
1550 diagnostics.count_n(
1551 "sew.output_faces",
1552 result
1553 .solid
1554 .shells
1555 .iter()
1556 .map(|shell| shell.faces.len() as u64)
1557 .sum(),
1558 );
1559 diagnostics.count_n("sew.face_images", result.face_images.len() as u64);
1560 let validation = result.solid.validate_detailed(&policy);
1561 diagnostics.measure_max("validate.max_pcurve_error", validation.max_pcurve_error);
1562 diagnostics.count_n("validate.issues", validation.issues.len() as u64);
1563 diagnostics.count_n(
1564 "validate.wire_warnings",
1565 validation.wire_warnings.len() as u64,
1566 );
1567 for warning in validation.wire_warnings {
1568 diagnostics.event(
1569 DiagnosticSeverity::Warning,
1570 KernelStage::Validate,
1571 "validate.uv_wire",
1572 warning.message,
1573 );
1574 }
1575 if !validation.issues.is_empty() {
1576 for issue in &validation.issues {
1577 diagnostics.event(
1578 DiagnosticSeverity::Error,
1579 KernelStage::Validate,
1580 "validate.brep",
1581 issue.message.clone(),
1582 );
1583 }
1584 return Err(format!(
1585 "offset_shell: invalid diagnostic result: {:?}",
1586 validation.issues
1587 ));
1588 }
1589 Ok(KernelOutcome {
1590 value: result,
1591 diagnostics,
1592 })
1593}