1use super::*;
2
3struct HealPlan {
4 primary: [usize; 2],
6 lateral: [usize; 2],
8 lateral_point: HashMap<usize, Vec3>,
10}
11
12fn plan_heal(planes: &[Plane; 4], f_center: Vec3, f_reach: f64) -> Result<HealPlan, String> {
15 let mut candidates: Vec<HealPlan> = Vec::new();
16 for start in 0..2usize {
17 let primary = [start, start + 2];
18 let lateral = [(start + 1) % 4, (start + 3) % 4];
19 let Some(line) = intersect_planes(&planes[primary[0]], &planes[primary[1]]) else {
20 continue;
21 };
22 let mut lateral_point = HashMap::default();
23 let mut ok = true;
24 for &lat in &lateral {
25 match intersect_line_plane(&line, &planes[lat]) {
26 Some(point) if point.sub(f_center).length() <= f_reach => {
27 lateral_point.insert(lat, point);
28 }
29 _ => {
30 ok = false;
31 break;
32 }
33 }
34 }
35 if !ok {
36 continue;
37 }
38 candidates.push(HealPlan {
39 primary,
40 lateral,
41 lateral_point,
42 });
43 }
44 match candidates.len() {
45 1 => Ok(candidates.pop().unwrap()),
46 0 => Err(
47 "delete_face_and_heal: the neighbours do not re-intersect cleanly \
48 (parallel planes, non-adjacent healing, or a multi-face gap) — refusing \
49 rather than emitting an invalid solid"
50 .into(),
51 ),
52 _ => Err(
53 "delete_face_and_heal: healing is ambiguous — both opposite neighbour \
54 pairs re-intersect through the deleted face"
55 .into(),
56 ),
57 }
58}
59
60pub(super) use crate::offset_retrim::retrim_planar_face;
65
66pub(super) fn locate_coedge(face: &FaceRecord, edge_id: u64) -> Option<(usize, usize)> {
69 for (loop_index, loop_record) in face.loops.iter().enumerate() {
70 for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
71 if coedge.edge_id == edge_id {
72 return Some((loop_index, coedge_index));
73 }
74 }
75 }
76 None
77}
78
79pub(super) fn coedge_from_vertex(coedge: &CoedgeRecord, edges: &HashMap<u64, EdgeRecord>) -> Option<u64> {
80 let edge = edges.get(&coedge.edge_id)?;
81 Some(if coedge.forward {
82 edge.start_vertex_id
83 } else {
84 edge.end_vertex_id
85 })
86}
87
88pub(super) fn coedge_to_vertex(coedge: &CoedgeRecord, edges: &HashMap<u64, EdgeRecord>) -> Option<u64> {
89 let edge = edges.get(&coedge.edge_id)?;
90 Some(if coedge.forward {
91 edge.end_vertex_id
92 } else {
93 edge.start_vertex_id
94 })
95}
96
97pub fn delete_face_and_heal(solid: &BrepSolid, face_id: u64) -> Result<BrepSolid, String> {
102 let mut solid = solid.clone();
103 let scale = solid_model_scale(&solid);
104 let tolerance = (scale * 1e-7).max(1e-9);
105
106 let (shell_index, face_index) = find_face(&solid, face_id)
107 .ok_or_else(|| format!("delete_face_and_heal: no face with id {face_id}"))?;
108
109 let boundary: Vec<(u64, bool)> = {
111 let face = &solid.shells[shell_index].faces[face_index];
112 if face.loops.len() != 1 {
113 return Err(format!(
114 "delete_face_and_heal: face {face_id} has {} loops; only a simple \
115 single-loop transition face is supported",
116 face.loops.len()
117 ));
118 }
119 face.loops[0]
120 .coedges
121 .iter()
122 .map(|coedge| (coedge.edge_id, coedge.forward))
123 .collect()
124 };
125 if boundary.len() != 4 {
126 return Err(format!(
127 "delete_face_and_heal: face {face_id} has {} boundary edges; only 4-sided \
128 transition faces (a single chamfer/fillet edge) are supported \
129 (deferred: multi-face gaps)",
130 boundary.len()
131 ));
132 }
133 let boundary_edge_ids: HashSet<u64> = boundary.iter().map(|(edge_id, _)| *edge_id).collect();
134 if boundary_edge_ids.len() == 3 {
135 return heal_closed_transition(&solid, shell_index, face_index, &boundary);
139 }
140 if boundary_edge_ids.len() != 4 {
141 return Err(
142 "delete_face_and_heal: transition face uses an edge more than once \
143 (deferred: periodic/closed transition)"
144 .into(),
145 );
146 }
147
148 let mut neighbour_ids = [0u64; 4];
150 for (index, (edge_id, _)) in boundary.iter().enumerate() {
151 neighbour_ids[index] = other_face_of_edge(&solid, *edge_id, face_id)?;
152 }
153 let unique: HashSet<u64> = neighbour_ids.iter().copied().collect();
154 if unique.len() != 4 {
155 return Err(
156 "delete_face_and_heal: the transition face touches a neighbour more \
157 than once (deferred: periodic/closed transition)"
158 .into(),
159 );
160 }
161
162 let neighbour_planes: [Plane; 4] = {
166 let mut planes: Vec<Option<Plane>> = Vec::with_capacity(4);
167 for &neighbour_id in &neighbour_ids {
168 let (ns, nf) = find_face(&solid, neighbour_id)
169 .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
170 planes.push(
171 plane_of_surface(
172 &solid.shells[ns].faces[nf].surface,
173 (scale * 1e-6).max(1e-7),
174 "delete_face_and_heal",
175 )
176 .ok(),
177 );
178 }
179 if planes.iter().any(Option::is_none) {
180 return heal_open_transition_mixed(
181 &solid,
182 shell_index,
183 face_index,
184 &boundary,
185 &neighbour_ids,
186 );
187 }
188 [
189 planes[0].unwrap(),
190 planes[1].unwrap(),
191 planes[2].unwrap(),
192 planes[3].unwrap(),
193 ]
194 };
195
196 let transition_vertices: Vec<u64> = boundary
198 .iter()
199 .map(|(edge_id, forward)| {
200 let edge = solid
201 .edges
202 .iter()
203 .find(|edge| edge.id == *edge_id)
204 .ok_or_else(|| format!("delete_face_and_heal: missing edge {edge_id}"))?;
205 Ok(if *forward {
206 edge.start_vertex_id
207 } else {
208 edge.end_vertex_id
209 })
210 })
211 .collect::<Result<Vec<u64>, String>>()?;
212 if transition_vertices.iter().collect::<HashSet<_>>().len() != 4 {
213 return Err(
214 "delete_face_and_heal: transition face has repeated corner vertices \
215 (deferred: degenerate transition)"
216 .into(),
217 );
218 }
219 let mut f_center = Vec3::default();
220 for &vertex_id in &transition_vertices {
221 f_center = f_center.add(edge_point(&solid, vertex_id)?);
222 }
223 f_center = f_center.scale(0.25);
224 let mut f_reach = 0.0f64;
225 for &vertex_id in &transition_vertices {
226 f_reach = f_reach.max(edge_point(&solid, vertex_id)?.sub(f_center).length());
227 }
228 let f_reach = f_reach * 3.0 + tolerance;
229
230 let plan = plan_heal(&neighbour_planes, f_center, f_reach)?;
231
232 let mut next_id = max_topology_id(&solid) + 1;
234 let mut alloc = || {
235 let value = next_id;
236 next_id += 1;
237 value
238 };
239
240 let lateral_a = plan.lateral[0];
241 let lateral_b = plan.lateral[1];
242 let point_a = plan.lateral_point[&lateral_a];
243 let point_b = plan.lateral_point[&lateral_b];
244 if point_a.sub(point_b).length() <= tolerance {
245 return Err(
246 "delete_face_and_heal: recovered corners coincide — the neighbours \
247 do not bound a clean edge"
248 .into(),
249 );
250 }
251 let vertex_a = alloc();
252 let vertex_b = alloc();
253 let mut lateral_vertex: HashMap<usize, u64> = HashMap::default();
254 lateral_vertex.insert(lateral_a, vertex_a);
255 lateral_vertex.insert(lateral_b, vertex_b);
256
257 let sharp_edge_id = alloc();
259 let sharp_edge = EdgeRecord {
260 id: sharp_edge_id,
261 curve: make_line(point_a, point_b)?,
262 t0: 0.0,
263 t1: 1.0,
264 start_vertex_id: vertex_a,
265 end_vertex_id: vertex_b,
266 degenerate: false,
267 name: None,
268 };
269
270 let lateral_set: HashSet<usize> = plan.lateral.iter().copied().collect();
275 let mut collapse: HashMap<u64, u64> = HashMap::default();
276 for index in 0..4usize {
277 let previous = (index + 3) % 4;
278 let lateral_index = if lateral_set.contains(&previous) {
279 previous
280 } else if lateral_set.contains(&index) {
281 index
282 } else {
283 return Err(
284 "delete_face_and_heal: transition corner is not flanked by a \
285 lateral face (unexpected neighbour ordering)"
286 .into(),
287 );
288 };
289 let target = lateral_vertex[&lateral_index];
290 collapse.insert(transition_vertices[index], target);
291 }
292 let new_vertex_points: HashMap<u64, Vec3> = [(vertex_a, point_a), (vertex_b, point_b)]
293 .into_iter()
294 .collect();
295
296 for edge in &mut solid.edges {
298 if boundary_edge_ids.contains(&edge.id) {
299 continue;
300 }
301 let start_target = collapse.get(&edge.start_vertex_id).copied();
302 let end_target = collapse.get(&edge.end_vertex_id).copied();
303 if start_target.is_none() && end_target.is_none() {
304 continue;
305 }
306 if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
307 return Err(
308 "delete_face_and_heal: a side edge meeting the transition face is \
309 not a straight line (deferred: curved neighbour edges)"
310 .into(),
311 );
312 }
313 let mut start_point = edge.curve.control_points[0].point()?;
314 let mut end_point = edge.curve.control_points[1].point()?;
315 if let Some(target) = start_target {
316 edge.start_vertex_id = target;
317 start_point = new_vertex_points[&target];
318 }
319 if let Some(target) = end_target {
320 edge.end_vertex_id = target;
321 end_point = new_vertex_points[&target];
322 }
323 if start_point.sub(end_point).length() <= tolerance {
324 return Err(
325 "delete_face_and_heal: healing would collapse a side edge to zero \
326 length (deferred: degenerate transition)"
327 .into(),
328 );
329 }
330 edge.curve = make_line(start_point, end_point)?;
331 edge.t0 = 0.0;
332 edge.t1 = 1.0;
333 }
334
335 let mut edges_by_id: HashMap<u64, EdgeRecord> = solid
337 .edges
338 .iter()
339 .map(|edge| (edge.id, edge.clone()))
340 .collect();
341 edges_by_id.insert(sharp_edge_id, sharp_edge.clone());
342
343 for &primary_index in &plan.primary {
347 let primary_edge = boundary[primary_index].0;
348 let neighbour_id = neighbour_ids[primary_index];
349 let (ns, nf) = find_face(&solid, neighbour_id)
350 .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
351 let face = &mut solid.shells[ns].faces[nf];
352 let (loop_index, coedge_index) = locate_coedge(face, primary_edge).ok_or_else(|| {
353 format!(
354 "delete_face_and_heal: neighbour {neighbour_id} does not use edge {primary_edge}"
355 )
356 })?;
357 let coedges = &face.loops[loop_index].coedges;
358 let count = coedges.len();
359 let previous = &coedges[(coedge_index + count - 1) % count];
360 let next = &coedges[(coedge_index + 1) % count];
361 let required_from = coedge_to_vertex(previous, &edges_by_id).ok_or_else(|| {
362 "delete_face_and_heal: could not resolve loop connectivity".to_string()
363 })?;
364 let required_to = coedge_from_vertex(next, &edges_by_id).ok_or_else(|| {
365 "delete_face_and_heal: could not resolve loop connectivity".to_string()
366 })?;
367 let forward = if required_from == vertex_a && required_to == vertex_b {
368 true
369 } else if required_from == vertex_b && required_to == vertex_a {
370 false
371 } else {
372 return Err(
373 "delete_face_and_heal: new edge does not close the primary loop \
374 (unexpected connectivity)"
375 .into(),
376 );
377 };
378 let new_coedge = CoedgeRecord {
379 id: alloc(),
380 edge_id: sharp_edge_id,
381 forward,
382 pcurve: make_line(Vec3::default(), Vec3::new(1.0, 0.0, 0.0))?,
384 };
385 face.loops[loop_index].coedges[coedge_index] = new_coedge;
386 }
387
388 for &lateral_index in &plan.lateral {
391 let lateral_edge = boundary[lateral_index].0;
392 let neighbour_id = neighbour_ids[lateral_index];
393 let (ns, nf) = find_face(&solid, neighbour_id)
394 .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
395 let face = &mut solid.shells[ns].faces[nf];
396 let (loop_index, coedge_index) = locate_coedge(face, lateral_edge).ok_or_else(|| {
397 format!(
398 "delete_face_and_heal: neighbour {neighbour_id} does not use edge {lateral_edge}"
399 )
400 })?;
401 face.loops[loop_index].coedges.remove(coedge_index);
402 if face.loops[loop_index].coedges.is_empty() {
403 return Err("delete_face_and_heal: healing emptied a lateral face loop".into());
404 }
405 }
406
407 solid.shells[shell_index]
409 .faces
410 .retain(|face| face.id != face_id);
411 solid
412 .edges
413 .retain(|edge| !boundary_edge_ids.contains(&edge.id));
414 let removed_vertices: HashSet<u64> = transition_vertices.iter().copied().collect();
415 solid.edges.push(sharp_edge);
416 solid
417 .vertices
418 .retain(|vertex| !removed_vertices.contains(&vertex.id));
419 solid.vertices.push(VertexRecord {
420 id: vertex_a,
421 point: point_a,
422 });
423 solid.vertices.push(VertexRecord {
424 id: vertex_b,
425 point: point_b,
426 });
427
428 let final_edges: HashMap<u64, EdgeRecord> = solid
430 .edges
431 .iter()
432 .map(|edge| (edge.id, edge.clone()))
433 .collect();
434 for (index, &neighbour_id) in neighbour_ids.iter().enumerate() {
435 let plane = neighbour_planes[index];
436 let (ns, nf) = find_face(&solid, neighbour_id)
437 .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
438 retrim_planar_face(
439 &mut solid.shells[ns].faces[nf],
440 &plane,
441 &final_edges,
442 scale,
443 "delete_face_and_heal",
444 )?;
445 }
446
447 let issues = solid.validate();
450 if !issues.is_empty() {
451 return Err(format!(
452 "delete_face_and_heal: healed solid failed validation: {issues:?}"
453 ));
454 }
455 Ok(solid)
456}
457
458pub fn resolve_face_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, String> {
461 let scale = solid_model_scale(&solid);
462 let mut best: Option<(u64, f64)> = None;
463 for shell in &solid.shells {
464 for face in &shell.faces {
465 let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else {
466 continue;
467 };
468 if best
469 .map(|(_, known)| projection.distance < known)
470 .unwrap_or(true)
471 {
472 best = Some((face.id, projection.distance));
473 }
474 }
475 }
476 match best {
477 Some((face_id, distance)) if distance <= (scale * 1e-3).max(1e-4) => Ok(face_id),
478 Some((_, distance)) => Err(format!(
479 "delete_face_and_heal: no face within tolerance of the point (nearest {distance:.6})"
480 )),
481 None => Err("delete_face_and_heal: solid has no faces".into()),
482 }
483}
484
485#[cfg(test)]
497mod delete_face_tests {
498 use super::*;
499 use crate::{
500 boolean_operation, chamfer_edge, fillet_edge, make_box_brep, make_cone_brep,
501 make_cylinder_brep, make_sphere_brep, solid_mass_properties, BooleanOperation,
502 BooleanOptions,
503 };
504
505 fn closed_rim_at(solid: &BrepSolid, z: f64) -> u64 {
507 solid
508 .edges
509 .iter()
510 .find(|edge| {
511 !edge.degenerate
512 && edge.start_vertex_id == edge.end_vertex_id
513 && edge
514 .curve
515 .evaluate(0.5 * (edge.t0 + edge.t1))
516 .map(|point| (point.z - z).abs() < 1e-6)
517 .unwrap_or(false)
518 })
519 .expect("closed rim")
520 .id
521 }
522
523 fn named_face(solid: &BrepSolid, name: &str) -> u64 {
524 solid
525 .shells
526 .iter()
527 .flat_map(|shell| &shell.faces)
528 .find(|face| face.name.as_deref() == Some(name))
529 .expect("named blend face")
530 .id
531 }
532
533 fn volume(solid: &BrepSolid) -> f64 {
534 solid_mass_properties(solid)
535 .expect("mass properties")
536 .volume
537 }
538
539 fn round_trip(
543 sharp: &BrepSolid,
544 rim: u64,
545 blend: fn(&BrepSolid, u64, f64, Option<&str>) -> Result<BrepSolid, String>,
546 size: f64,
547 ) -> BrepSolid {
548 let faces_before: usize = sharp.shells.iter().map(|shell| shell.faces.len()).sum();
549 let volume_before = volume(sharp);
550 let blended = blend(sharp, rim, size, Some("B1")).expect("blend");
551 assert!(blended.validate().is_empty(), "{:?}", blended.validate());
552 let strip = named_face(&blended, "B1");
553
554 let healed = delete_face_and_heal(&blended, strip).expect("heal");
555 assert!(
556 healed.validate().is_empty(),
557 "healed solid must validate: {:?}",
558 healed.validate()
559 );
560 assert_eq!(
561 healed
562 .shells
563 .iter()
564 .map(|shell| shell.faces.len())
565 .sum::<usize>(),
566 faces_before,
567 "face count must return to the pre-blend count"
568 );
569 let volume_after = volume(&healed);
570 assert!(
571 (volume_after - volume_before).abs() <= 1e-6 * volume_before.abs(),
572 "expected the pre-blend volume {volume_before}, got {volume_after}"
573 );
574 healed
575 }
576
577 #[test]
584 fn closed_heal_restores_the_cylinder_after_a_rim_chamfer() {
585 let cylinder =
586 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 6.0).unwrap();
587 let rim = closed_rim_at(&cylinder, 6.0);
588 let healed = round_trip(&cylinder, rim, chamfer_edge, 1.0);
589 assert!(healed.edges.iter().any(|edge| {
591 let Ok(point) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
592 return false;
593 };
594 (point.z - 6.0).abs() < 1e-6 && (point.x.hypot(point.y) - 4.0).abs() < 1e-6
595 }));
596 }
597
598 #[test]
602 fn closed_heal_restores_the_cone_frustum_after_a_rim_fillet() {
603 let cone =
604 make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 2.5, 6.0).unwrap();
605 let rim = closed_rim_at(&cone, 6.0);
606 let healed = round_trip(&cone, rim, fillet_edge, 0.8);
607 assert!(healed.edges.iter().any(|edge| {
608 let Ok(point) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
609 return false;
610 };
611 (point.z - 6.0).abs() < 1e-6 && (point.x.hypot(point.y) - 2.5).abs() < 1e-6
612 }));
613 }
614
615 fn upper_half_ball(radius: f64) -> BrepSolid {
618 let sphere = make_sphere_brep(Vec3::default(), radius, Vec3::new(0.0, 0.0, 1.0)).unwrap();
619 let box_up = make_box_brep(
620 Vec3::new(-2.0 * radius, -2.0 * radius, 0.0),
621 4.0 * radius,
622 4.0 * radius,
623 2.0 * radius,
624 )
625 .unwrap();
626 let options = BooleanOptions {
627 merge_coplanar_faces: true,
628 ..BooleanOptions::default()
629 };
630 boolean_operation(&sphere, &box_up, BooleanOperation::Intersect, &options).unwrap()
631 }
632
633 #[test]
639 fn closed_heal_rebinds_a_sphere_neighbour_after_a_rim_fillet() {
640 let ball = upper_half_ball(5.0);
641 let rim = closed_rim_at(&ball, 0.0);
642 let healed = round_trip(&ball, rim, fillet_edge, 0.8);
643 assert!(healed.edges.iter().any(|edge| {
645 let Ok(point) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
646 return false;
647 };
648 point.z.abs() < 1e-6 && (point.x.hypot(point.y) - 5.0).abs() < 1e-6
649 }));
650 for edge in &healed.edges {
653 if edge.degenerate {
654 continue;
655 }
656 for step in 0..=8 {
657 let t = edge.t0 + (edge.t1 - edge.t0) * f64::from(step) / 8.0;
658 let point = edge.curve.evaluate(t).unwrap();
659 let on_sphere = (point.length() - 5.0).abs() < 1e-6;
660 let on_base = point.z.abs() < 1e-6 && point.length() <= 5.0 + 1e-6;
661 assert!(
662 on_sphere || on_base,
663 "edge {} left both carriers at t={t}: {point:?}",
664 edge.id
665 );
666 }
667 }
668 }
669
670 #[test]
672 fn closed_heal_rebinds_a_sphere_neighbour_after_a_rim_chamfer() {
673 let ball = upper_half_ball(5.0);
674 let rim = closed_rim_at(&ball, 0.0);
675 round_trip(&ball, rim, chamfer_edge, 0.8);
676 }
677
678 #[test]
684 fn closed_heal_rebinds_a_torus_neighbour_after_a_rim_fillet() {
685 let torus =
686 crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 2.0).unwrap();
687 let box_up = make_box_brep(Vec3::new(-20.0, -20.0, 0.0), 40.0, 40.0, 20.0).unwrap();
688 let options = BooleanOptions {
689 merge_coplanar_faces: true,
690 ..BooleanOptions::default()
691 };
692 let half =
693 boolean_operation(&torus, &box_up, BooleanOperation::Intersect, &options).unwrap();
694 let expected = std::f64::consts::PI.powi(2) * 5.0 * 4.0;
696 assert!((volume(&half) - expected).abs() <= 1e-6 * expected);
697 let rim = closed_rim_at(&half, 0.0);
698 let healed = round_trip(&half, rim, fillet_edge, 0.4);
699 assert!((volume(&healed) - expected).abs() <= 1e-6 * expected);
700 }
701
702 #[test]
705 fn refuses_a_face_that_does_not_exist() {
706 let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
707 let error = delete_face_and_heal(&cube, 999_999).unwrap_err();
708 assert!(
709 error.contains("no face with id 999999"),
710 "unexpected refusal: {error}"
711 );
712 }
713
714 #[test]
719 fn refuses_a_multi_loop_face_naming_the_loop_count() {
720 let plate = make_box_brep(Vec3::default(), 20.0, 20.0, 4.0).unwrap();
721 let drill = make_cylinder_brep(
722 Vec3::new(10.0, 10.0, -1.0),
723 Vec3::new(0.0, 0.0, 1.0),
724 3.0,
725 6.0,
726 )
727 .unwrap();
728 let drilled = boolean_operation(
729 &plate,
730 &drill,
731 BooleanOperation::Subtract,
732 &BooleanOptions::default(),
733 )
734 .unwrap();
735 let top = resolve_face_by_point(&drilled, Vec3::new(2.0, 2.0, 4.0)).unwrap();
736 let error = delete_face_and_heal(&drilled, top).unwrap_err();
737 assert!(
738 error.contains("2 loops") && error.contains("single-loop"),
739 "refusal must name the loop count: {error}"
740 );
741 }
742
743 #[test]
747 fn refuses_a_face_that_is_not_four_sided() {
748 let cube = make_box_brep(Vec3::default(), 10.0, 10.0, 10.0).unwrap();
749 let profile = [
750 make_line(Vec3::new(8.0, 0.0, 10.0), Vec3::new(0.0, 8.0, 10.0)).unwrap(),
751 make_line(Vec3::new(0.0, 8.0, 10.0), Vec3::new(20.0, 20.0, 10.0)).unwrap(),
752 make_line(Vec3::new(20.0, 20.0, 10.0), Vec3::new(8.0, 0.0, 10.0)).unwrap(),
753 ];
754 let cutter =
755 crate::extrude_profile_brep(&profile, Vec3::new(0.0, 0.0, -1.0), 8.0).unwrap();
756 let cut = boolean_operation(
757 &cube,
758 &cutter,
759 BooleanOperation::Subtract,
760 &BooleanOptions::default(),
761 )
762 .unwrap();
763 let triangle = cut
764 .shells
765 .iter()
766 .flat_map(|shell| &shell.faces)
767 .find(|face| face.loops.len() == 1 && face.loops[0].coedges.len() == 3)
768 .expect("3-sided cut face")
769 .id;
770 let error = delete_face_and_heal(&cut, triangle).unwrap_err();
771 assert!(
772 error.contains("3 boundary edges") && error.contains("4-sided"),
773 "refusal must name the side count: {error}"
774 );
775 }
776
777 #[test]
805 fn the_shared_reintersect_seam_declines_the_chamfered_cube_primaries() {
806 let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
807 let edge = cube
808 .edges
809 .iter()
810 .find(|edge| {
811 edge.curve
812 .evaluate(0.5 * (edge.t0 + edge.t1))
813 .map(|point| (point.x - 1.0).abs() < 1e-9 && (point.z - 1.0).abs() < 1e-9)
814 .unwrap_or(false)
815 })
816 .expect("top-right cube edge")
817 .id;
818 let chamfered = chamfer_edge(&cube, edge, 0.2, Some("C1")).unwrap();
819 let strip = named_face(&chamfered, "C1");
820
821 let top = resolve_face_by_point(&chamfered, Vec3::new(0.4, 0.5, 1.0)).unwrap();
825 let right = resolve_face_by_point(&chamfered, Vec3::new(1.0, 0.5, 0.4)).unwrap();
826 assert_ne!(top, strip);
827 assert_ne!(right, strip);
828 let surface_of = |id: u64| {
829 chamfered
830 .shells
831 .iter()
832 .flat_map(|shell| &shell.faces)
833 .find(|face| face.id == id)
834 .map(|face| face.surface.clone())
835 .expect("face")
836 };
837
838 let mut seeds: Vec<Vec3> = Vec::new();
841 for edge in &chamfered.edges {
842 for step in 0..=4 {
843 let t = edge.t0 + (edge.t1 - edge.t0) * f64::from(step) / 4.0;
844 if let Ok(point) = edge.curve.evaluate(t) {
845 seeds.push(point);
846 }
847 }
848 }
849 let policy = MarchPolicy {
850 tolerance: 1e-7,
851 residual_tolerance: 1e-5,
852 seeds,
853 };
854 let found = reintersect_carriers(&surface_of(top), &surface_of(right), &policy)
855 .expect("the seam answers this pair");
856 assert!(
857 matches!(found.lane, RimLane::Marched),
858 "plane x plane has no closed form, so the answer must come from the \
859 marched lane; got the analytic one"
860 );
861 assert_eq!(found.sections.len(), 1, "one line, traced");
862 let section = &found.sections[0];
865 let [s0, s1] = section.curve.domain().unwrap();
866 let mut worst = 0.0f64;
867 for step in 0..=16 {
868 let point = section
869 .curve
870 .evaluate(s0 + (s1 - s0) * f64::from(step) / 16.0)
871 .unwrap();
872 worst = worst.max((point.x - 1.0).abs().max((point.z - 1.0).abs()));
873 }
874 assert!(worst <= 1e-5, "the marched line drifts {worst} off x=1,z=1");
875
876 let healed = delete_face_and_heal(&chamfered, strip).unwrap();
878 assert!(healed.validate().is_empty());
879 assert!((volume(&healed) - 1.0).abs() < 1e-9);
880 }
881}