1use crate::analytic_surface::circumcenter;
2use crate::mass_properties::{face_area, parameter_space_area};
3use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord};
4use crate::{
5 build_pcurve_on_surface, make_line, make_plane, make_revolution, KnotVector, NurbsCurve,
6 NurbsSurface, Vec3, Vec4,
7};
8use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
9
10fn controls_match(first: Vec4, second: Vec4, tolerance: f64) -> bool {
11 (first.x - second.x).abs() <= tolerance
12 && (first.y - second.y).abs() <= tolerance
13 && (first.z - second.z).abs() <= tolerance
14 && (first.w - second.w).abs() <= tolerance
15}
16
17fn same_surface(first: &NurbsSurface, second: &NurbsSurface, tolerance: f64) -> bool {
18 first.degree_u == second.degree_u
19 && first.degree_v == second.degree_v
20 && first.knots_u.len() == second.knots_u.len()
21 && first.knots_v.len() == second.knots_v.len()
22 && first.control_points.len() == second.control_points.len()
23 && first
24 .control_points
25 .iter()
26 .zip(&second.control_points)
27 .all(|(a, b)| {
28 a.len() == b.len()
29 && a.iter()
30 .zip(b)
31 .all(|(a, b)| controls_match(*a, *b, tolerance))
32 })
33 && first
34 .knots_u
35 .iter()
36 .zip(&second.knots_u)
37 .all(|(a, b)| (a - b).abs() <= tolerance)
38 && first
39 .knots_v
40 .iter()
41 .zip(&second.knots_v)
42 .all(|(a, b)| (a - b).abs() <= tolerance)
43}
44
45fn surface_domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
46 Ok((
47 KnotVector::new(surface.knots_u.clone(), surface.degree_u)?.domain(),
48 KnotVector::new(surface.knots_v.clone(), surface.degree_v)?.domain(),
49 ))
50}
51
52fn outward_normal(face: &FaceRecord) -> Result<Vec3, String> {
53 let (u, v) = surface_domains(&face.surface)?;
54 let normal = face
55 .surface
56 .normal((u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0)?;
57 Ok(if face.same_sense {
58 normal
59 } else {
60 normal.scale(-1.0)
61 })
62}
63
64fn planar_support(face: &FaceRecord, tolerance: f64) -> Result<Option<(Vec3, Vec3)>, String> {
65 let (u, v) = surface_domains(&face.surface)?;
66 let midpoint_u = (u[0] + u[1]) / 2.0;
67 let midpoint_v = (v[0] + v[1]) / 2.0;
68 let origin = face.surface.evaluate(midpoint_u, midpoint_v)?;
69 let normal = face.surface.normal(midpoint_u, midpoint_v)?;
70 let controls = face
71 .surface
72 .control_points
73 .iter()
74 .flatten()
75 .map(|control| control.point())
76 .collect::<Result<Vec<_>, _>>()?;
77 let scale = controls
78 .iter()
79 .map(|point| point.sub(origin).length())
80 .fold(1.0f64, f64::max);
81 let plane_tolerance = (tolerance * 100.0).max(1e-7) * scale;
82 if controls
83 .iter()
84 .all(|point| point.sub(origin).dot(normal).abs() <= plane_tolerance)
85 {
86 Ok(Some((origin, normal)))
87 } else {
88 Ok(None)
89 }
90}
91
92fn coplanar_with_same_outward_normal(
93 first: &FaceRecord,
94 second: &FaceRecord,
95 tolerance: f64,
96) -> Result<bool, String> {
97 let Some((first_origin, first_geometric_normal)) = planar_support(first, tolerance)? else {
98 return Ok(false);
99 };
100 let Some((_, second_geometric_normal)) = planar_support(second, tolerance)? else {
101 return Ok(false);
102 };
103 if first_geometric_normal.dot(second_geometric_normal).abs() < 1.0 - 1e-7 {
104 return Ok(false);
105 }
106 let first_normal = outward_normal(first)?;
107 let second_normal = outward_normal(second)?;
108 if first_normal.dot(second_normal) < 1.0 - 1e-7 {
109 return Ok(false);
110 }
111 let (second_u, second_v) = surface_domains(&second.surface)?;
112 let plane_tolerance = (tolerance * 100.0).max(1e-6) * (1.0 + first_origin.length());
113 for u in second_u {
114 for v in second_v {
115 if second
116 .surface
117 .evaluate(u, v)?
118 .sub(first_origin)
119 .dot(first_normal)
120 .abs()
121 > plane_tolerance
122 {
123 return Ok(false);
124 }
125 }
126 }
127 Ok(true)
128}
129
130fn arc_circle_support(
134 curve: &NurbsCurve,
135 tolerance: f64,
136) -> Result<Option<(Vec3, Vec3, f64)>, String> {
137 let [t0, t1] = curve.domain()?;
138 let samples = (0..=8)
139 .map(|index| curve.evaluate(t0 + (t1 - t0) * index as f64 / 8.0))
140 .collect::<Result<Vec<_>, _>>()?;
141 let Some(center) = circumcenter(samples[0], samples[2], samples[5])
145 .or_else(|| circumcenter(samples[0], samples[4], samples[8]))
146 .or_else(|| circumcenter(samples[1], samples[4], samples[7]))
147 else {
148 return Ok(None);
149 };
150 let radius = samples[0].sub(center).length();
151 if radius <= tolerance {
152 return Ok(None);
153 }
154 let normal = match samples[2]
155 .sub(samples[0])
156 .cross(samples[5].sub(samples[0]))
157 .normalized()
158 .or_else(|_| {
159 samples[4]
160 .sub(samples[0])
161 .cross(samples[8].sub(samples[0]))
162 .normalized()
163 }) {
164 Ok(normal) => normal,
165 Err(_) => return Ok(None),
166 };
167 let band = tolerance.max(1e-9 * radius);
168 for sample in &samples {
169 let delta = sample.sub(center);
170 if (delta.length() - radius).abs() > band || delta.dot(normal).abs() > band {
171 if std::env::var("BREP_DEBUG_MERGE").is_ok() {
172 eprintln!(
173 " arc reject: radius={radius} dr={} dplane={} band={band}",
174 (delta.length() - radius).abs(),
175 delta.dot(normal).abs()
176 );
177 }
178 return Ok(None);
179 }
180 }
181 Ok(Some((center, normal, radius)))
182}
183
184struct CylinderSupport {
185 origin: Vec3,
186 axis: Vec3,
187 radius: f64,
188 outward_radial_sign: f64,
191}
192
193fn ruled_cylinder_support(
198 face: &FaceRecord,
199 tolerance: f64,
200) -> Result<Option<CylinderSupport>, String> {
201 let surface = &face.surface;
202 let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
203 let (arc_first, columns) = match (surface.degree_u, surface.degree_v) {
204 (2, 1) => (true, surface.control_points[0].len()),
205 (1, 2) => (false, surface.control_points.len()),
206 _ => {
207 if debug {
208 eprintln!(
209 " support face {}: degrees ({},{}) not ruled-arc",
210 face.id, surface.degree_u, surface.degree_v
211 );
212 }
213 return Ok(None);
214 }
215 };
216 let last = columns - 1;
220 let iso_curve = |side: usize| -> Result<NurbsCurve, String> {
221 let controls = if arc_first {
222 surface
223 .control_points
224 .iter()
225 .map(|row| row[side])
226 .collect::<Vec<_>>()
227 } else {
228 surface.control_points[side].clone()
229 };
230 let knots = if arc_first {
231 surface.knots_u.clone()
232 } else {
233 surface.knots_v.clone()
234 };
235 NurbsCurve::new(2, knots, controls)
236 };
237 let scale = surface
238 .control_points
239 .iter()
240 .flatten()
241 .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
242 .fold(1.0f64, f64::max);
243 let circle_tolerance = (tolerance * 100.0).max(1e-7 * scale);
244 let Some((center0, normal0, radius0)) = arc_circle_support(&iso_curve(0)?, circle_tolerance)?
245 else {
246 if debug {
247 eprintln!(" support face {}: first iso not circular", face.id);
248 }
249 return Ok(None);
250 };
251 let Some((center1, normal1, radius1)) =
252 arc_circle_support(&iso_curve(last)?, circle_tolerance)?
253 else {
254 if debug {
255 eprintln!(" support face {}: last iso not circular", face.id);
256 }
257 return Ok(None);
258 };
259 if (radius0 - radius1).abs() > circle_tolerance {
260 if debug {
261 eprintln!(
262 " support face {}: radii differ {} vs {}",
263 face.id, radius0, radius1
264 );
265 }
266 return Ok(None);
267 }
268 let axial = center1.sub(center0);
269 if axial.length() <= circle_tolerance {
270 if debug {
271 eprintln!(" support face {}: zero axial extent", face.id);
272 }
273 return Ok(None);
274 }
275 let axis = axial.normalized()?;
276 if axis.dot(normal0).abs() < 1.0 - 1e-7 || axis.dot(normal1).abs() < 1.0 - 1e-7 {
277 if debug {
278 eprintln!(
279 " support face {}: arc planes not perpendicular to axis",
280 face.id
281 );
282 }
283 return Ok(None);
284 }
285 let outward = outward_normal(face)?;
286 let (u, v) = surface_domains(surface)?;
287 let midpoint = surface.evaluate((u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0)?;
288 let foot = center0.add(axis.scale(midpoint.sub(center0).dot(axis)));
289 let radial = match midpoint.sub(foot).normalized() {
290 Ok(radial) => radial,
291 Err(_) => return Ok(None),
292 };
293 let alignment = outward.dot(radial);
294 if alignment.abs() < 0.9 {
295 if debug {
296 eprintln!(
297 " support face {}: outward not radial ({alignment})",
298 face.id
299 );
300 }
301 return Ok(None);
302 }
303 Ok(Some(CylinderSupport {
304 origin: center0,
305 axis,
306 radius: radius0,
307 outward_radial_sign: alignment.signum(),
308 }))
309}
310
311fn cosurface_cylinder_pair(
312 first: &FaceRecord,
313 second: &FaceRecord,
314 tolerance: f64,
315) -> Result<Option<CylinderSupport>, String> {
316 let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
317 let Some(support_a) = ruled_cylinder_support(first, tolerance)? else {
318 if debug {
319 eprintln!(
320 "merge {}x{}: first not a ruled cylinder",
321 first.id, second.id
322 );
323 }
324 return Ok(None);
325 };
326 let Some(support_b) = ruled_cylinder_support(second, tolerance)? else {
327 if debug {
328 eprintln!(
329 "merge {}x{}: second not a ruled cylinder",
330 first.id, second.id
331 );
332 }
333 return Ok(None);
334 };
335 let scale = 1.0f64
336 .max(support_a.origin.length())
337 .max(support_b.origin.length())
338 .max(support_a.radius);
339 let band = (tolerance * 100.0).max(1e-7 * scale);
340 if support_a.axis.dot(support_b.axis).abs() < 1.0 - 1e-7
341 || (support_a.radius - support_b.radius).abs() > band
342 || support_a.outward_radial_sign != support_b.outward_radial_sign
343 {
344 if debug {
345 eprintln!(
346 "merge {}x{}: axis/radius/sign mismatch dot={} dr={} signs=({},{})",
347 first.id,
348 second.id,
349 support_a.axis.dot(support_b.axis),
350 (support_a.radius - support_b.radius).abs(),
351 support_a.outward_radial_sign,
352 support_b.outward_radial_sign
353 );
354 }
355 return Ok(None);
356 }
357 let offset = support_b.origin.sub(support_a.origin);
358 let perpendicular = offset.sub(support_a.axis.scale(offset.dot(support_a.axis)));
359 if perpendicular.length() > band {
360 if debug {
361 eprintln!(
362 "merge {}x{}: axes offset {}",
363 first.id,
364 second.id,
365 perpendicular.length()
366 );
367 }
368 return Ok(None);
369 }
370 if debug {
371 eprintln!(
372 "merge {}x{}: MATCH r={} axis=({:.4},{:.4},{:.4})",
373 first.id,
374 second.id,
375 support_a.radius,
376 support_a.axis.x,
377 support_a.axis.y,
378 support_a.axis.z
379 );
380 }
381 Ok(Some(support_a))
382}
383
384struct ExtrusionSupport {
385 base: NurbsCurve,
387 direction: Vec3,
389 interval: [f64; 2],
391}
392
393fn ruled_extrusion_support(
399 face: &FaceRecord,
400 tolerance: f64,
401) -> Result<Option<ExtrusionSupport>, String> {
402 let surface = &face.surface;
403 if planar_support(face, tolerance)?.is_some() {
406 return Ok(None);
407 }
408 let profile_first = if surface.degree_v == 1 && surface.degree_u >= 1 {
409 true
410 } else if surface.degree_u == 1 && surface.degree_v >= 1 {
411 false
412 } else {
413 return Ok(None);
414 };
415 let (profile_count, linear_count) = if profile_first {
416 (
417 surface.control_points.len(),
418 surface.control_points[0].len(),
419 )
420 } else {
421 (
422 surface.control_points[0].len(),
423 surface.control_points.len(),
424 )
425 };
426 if linear_count < 2 {
427 return Ok(None);
428 }
429 let control = |profile_index: usize, linear_index: usize| -> Vec4 {
430 if profile_first {
431 surface.control_points[profile_index][linear_index]
432 } else {
433 surface.control_points[linear_index][profile_index]
434 }
435 };
436 let scale = surface
437 .control_points
438 .iter()
439 .flatten()
440 .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
441 .fold(1.0f64, f64::max);
442 let band = (tolerance * 100.0).max(1e-7 * scale);
443 let mut translation: Option<Vec3> = None;
444 for profile_index in 0..profile_count {
445 let first = control(profile_index, 0);
446 let last = control(profile_index, linear_count - 1);
447 if (first.w - last.w).abs() > 1e-9 * first.w.abs().max(1.0) {
448 return Ok(None);
449 }
450 let step = last.point()?.sub(first.point()?);
451 if let Some(reference) = translation {
452 if step.sub(reference).length() > band {
453 return Ok(None);
454 }
455 } else {
456 translation = Some(step);
457 }
458 let start = first.point()?;
459 for linear_index in 1..linear_count - 1 {
460 let middle = control(profile_index, linear_index);
461 if (middle.w - first.w).abs() > 1e-9 * first.w.abs().max(1.0) {
462 return Ok(None);
463 }
464 let offset = middle.point()?.sub(start);
465 let along = offset.dot(step) / step.dot(step).max(1e-30);
466 if offset.sub(step.scale(along)).length() > band
467 || !(-1e-9..=1.0 + 1e-9).contains(&along)
468 {
469 return Ok(None);
470 }
471 }
472 }
473 let translation = translation.ok_or("extrusion support: empty net")?;
474 if translation.length() <= band {
475 return Ok(None);
476 }
477 let direction = translation.normalized()?;
478 let base_controls = (0..profile_count).map(|index| control(index, 0)).collect();
479 let base_knots = if profile_first {
480 surface.knots_u.clone()
481 } else {
482 surface.knots_v.clone()
483 };
484 let base_degree = if profile_first {
485 surface.degree_u
486 } else {
487 surface.degree_v
488 };
489 let base = NurbsCurve::new(base_degree, base_knots, base_controls)?;
490 let [t0, t1] = base.domain()?;
491 let mut axial = [f64::INFINITY, f64::NEG_INFINITY];
492 for index in 0..=8 {
493 let z = base
494 .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)?
495 .dot(direction);
496 axial[0] = axial[0].min(z);
497 axial[1] = axial[1].max(z);
498 }
499 if axial[1] - axial[0] > band {
500 return Ok(None);
503 }
504 let start = (axial[0] + axial[1]) / 2.0;
505 Ok(Some(ExtrusionSupport {
506 base,
507 direction,
508 interval: [start, start + translation.length()],
509 }))
510}
511
512fn perpendicular_profile(curve: &NurbsCurve, direction: Vec3) -> Result<NurbsCurve, String> {
513 let controls = curve
514 .control_points
515 .iter()
516 .map(|control| {
517 let point = control.point()?;
518 Ok(Vec4::from_point(
519 point.sub(direction.scale(point.dot(direction))),
520 control.w,
521 ))
522 })
523 .collect::<Result<Vec<_>, String>>()?;
524 NurbsCurve::new(curve.degree, curve.knots.clone(), controls)
525}
526
527fn curves_coincide(first: &NurbsCurve, second: &NurbsCurve, band: f64) -> Result<bool, String> {
528 for (from, to) in [(first, second), (second, first)] {
529 let [t0, t1] = from.domain()?;
530 for index in 0..=8 {
531 let point = from.evaluate(t0 + (t1 - t0) * index as f64 / 8.0)?;
532 let projection = match crate::project_point_to_curve(to, point) {
533 Ok(projection) => projection,
534 Err(_) => return Ok(false),
535 };
536 if projection.distance > band {
537 return Ok(false);
538 }
539 }
540 }
541 Ok(true)
542}
543
544fn coextrusion_pair(
547 older: &FaceRecord,
548 newer: &FaceRecord,
549 tolerance: f64,
550) -> Result<Option<(ExtrusionSupport, [f64; 2])>, String> {
551 let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
552 let Some(support_a) = ruled_extrusion_support(older, tolerance)? else {
553 return Ok(None);
554 };
555 let Some(support_b) = ruled_extrusion_support(newer, tolerance)? else {
556 return Ok(None);
557 };
558 let alignment = support_a.direction.dot(support_b.direction);
559 if alignment.abs() < 1.0 - 1e-9 {
560 if debug {
561 eprintln!(
562 "coextrusion {}x{}: directions differ (dot {alignment})",
563 older.id, newer.id
564 );
565 }
566 return Ok(None);
567 }
568 let scale = 1.0f64
569 .max(support_a.interval[1].abs())
570 .max(support_b.interval[1].abs());
571 let band = (tolerance * 100.0).max(1e-7 * scale);
572 let profile_a = perpendicular_profile(&support_a.base, support_a.direction)?;
573 let profile_b = perpendicular_profile(&support_b.base, support_a.direction)?;
574 if !curves_coincide(&profile_a, &profile_b, band)? {
575 if debug {
576 eprintln!("coextrusion {}x{}: profiles differ", older.id, newer.id);
577 }
578 return Ok(None);
579 }
580 let interval_b = if alignment < 0.0 {
583 [-support_b.interval[1], -support_b.interval[0]]
584 } else {
585 support_b.interval
586 };
587 if interval_b[0] > support_a.interval[1] + band || support_a.interval[0] > interval_b[1] + band
588 {
589 if debug {
590 eprintln!(
591 "coextrusion {}x{}: axial gap {:?} vs {:?}",
592 older.id, newer.id, support_a.interval, interval_b
593 );
594 }
595 return Ok(None);
596 }
597 let union = [
598 support_a.interval[0].min(interval_b[0]),
599 support_a.interval[1].max(interval_b[1]),
600 ];
601 Ok(Some((support_a, union)))
602}
603
604fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, String> {
605 let [start, end] = curve.domain()?;
606 let epsilon = (1e-9 * (end - start)).max(2e-9);
607 let mut result = curve.clone();
608 if t0 > start + epsilon && t0 < end - epsilon {
609 result = result.split(t0)?.1;
610 }
611 let domain = result.domain()?;
612 if t1 < domain[1] - epsilon && t1 > domain[0] + epsilon {
613 result = result.split(t1)?.0;
614 } else if std::env::var("BREP_DEBUG_SUBRANGE").is_ok() && t1 < domain[1] - epsilon {
615 eprintln!("abnormal subrange skip in face_merge trimmed");
616 }
617 Ok(result)
618}
619
620fn reproject_loops(
621 surface: &NurbsSurface,
622 loops: Vec<LoopRecord>,
623 edges: &HashMap<u64, &EdgeRecord>,
624) -> Result<Vec<LoopRecord>, String> {
625 let mut projected_loops = Vec::new();
626 for (loop_index, loop_record) in loops.into_iter().enumerate() {
627 let mut projected = Vec::new();
628 for mut coedge in loop_record.coedges {
629 let edge = edges[&coedge.edge_id];
630 let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
631 if !coedge.forward {
632 curve = curve.reversed()?;
633 }
634 coedge.pcurve = build_pcurve_on_surface(surface, &curve)?;
635 projected.push(coedge);
636 }
637 projected_loops.push(LoopRecord {
638 id: loop_index as u64 + 1,
639 coedges: projected,
640 });
641 }
642 Ok(projected_loops)
643}
644
645fn traversal_vertices(edge: &EdgeRecord, coedge: &CoedgeRecord) -> (u64, u64) {
646 if coedge.forward {
647 (edge.start_vertex_id, edge.end_vertex_id)
648 } else {
649 (edge.end_vertex_id, edge.start_vertex_id)
650 }
651}
652
653fn try_merge_pair(
654 older: &FaceRecord,
655 newer: &FaceRecord,
656 edges: &HashMap<u64, &EdgeRecord>,
657 tolerance: f64,
658 same_carrier_area_tolerance_ratio: f64,
659) -> Result<Option<FaceRecord>, String> {
660 let same_carrier = older.same_sense == newer.same_sense
661 && same_surface(&older.surface, &newer.surface, tolerance);
662 let coplanar_planar =
663 !same_carrier && coplanar_with_same_outward_normal(older, newer, tolerance)?;
664 let cosurface_cylinder = if !same_carrier && !coplanar_planar {
665 cosurface_cylinder_pair(older, newer, tolerance)?
666 } else {
667 None
668 };
669 let cosurface_extrusion = if !same_carrier && !coplanar_planar && cosurface_cylinder.is_none() {
670 coextrusion_pair(older, newer, tolerance)?
671 } else {
672 None
673 };
674 let older_coedges = older
675 .loops
676 .iter()
677 .flat_map(|loop_record| &loop_record.coedges)
678 .collect::<Vec<_>>();
679 let newer_coedges = newer
680 .loops
681 .iter()
682 .flat_map(|loop_record| &loop_record.coedges)
683 .collect::<Vec<_>>();
684 let mut shared_older = HashSet::default();
685 let mut shared_newer = HashSet::default();
686 for first in &older_coedges {
687 if edges[&first.edge_id].degenerate {
688 continue;
689 }
690 for second in &newer_coedges {
691 if first.edge_id != second.edge_id {
692 continue;
693 }
694 if first.forward == second.forward {
695 return Ok(None);
696 }
697 shared_older.insert(first.id);
698 shared_newer.insert(second.id);
699 }
700 }
701 if shared_older.is_empty() || shared_older.len() != shared_newer.len() {
702 return Ok(None);
703 }
704 if !same_carrier
705 && !coplanar_planar
706 && cosurface_cylinder.is_none()
707 && cosurface_extrusion.is_none()
708 {
709 return Ok(None);
710 }
711 let maximum_cycle_length = older_coedges.len() + newer_coedges.len();
712 let mut remaining = older_coedges
713 .into_iter()
714 .filter(|coedge| {
715 !shared_older.contains(&coedge.id)
716 && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
717 })
718 .chain(newer_coedges.into_iter().filter(|coedge| {
719 !shared_newer.contains(&coedge.id)
720 && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
721 }))
722 .cloned()
723 .collect::<Vec<_>>();
724 let mut loops = Vec::new();
725 while !remaining.is_empty() {
726 let first = remaining.remove(0);
727 let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
728 let mut cycle = vec![first];
729 while end != start {
730 let matches = remaining
731 .iter()
732 .enumerate()
733 .filter_map(|(index, coedge)| {
734 (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end).then_some(index)
735 })
736 .collect::<Vec<_>>();
737 if matches.len() != 1 {
738 return Ok(None);
739 }
740 let next = remaining.remove(matches[0]);
741 end = traversal_vertices(edges[&next.edge_id], &next).1;
742 cycle.push(next);
743 if cycle.len() > maximum_cycle_length {
744 return Ok(None);
745 }
746 }
747 if !cycle
748 .iter()
749 .any(|coedge| !edges[&coedge.edge_id].degenerate)
750 {
751 return Ok(None);
752 }
753 loops.push(LoopRecord {
754 id: loops.len() as u64 + 1,
755 coedges: cycle,
756 });
757 }
758 if loops.is_empty() {
759 return Ok(None);
760 }
761 loops.sort_by(|first, second| {
762 let area = |loop_record: &LoopRecord| {
763 parameter_space_area(&FaceRecord {
764 id: older.id,
765 surface: older.surface.clone(),
766 same_sense: older.same_sense,
767 loops: vec![loop_record.clone()],
768 name: None,
769 })
770 .map(f64::abs)
771 };
772 match (area(first), area(second)) {
773 (Ok(a), Ok(b)) => b.total_cmp(&a),
774 _ => std::cmp::Ordering::Equal,
775 }
776 });
777 if same_carrier {
778 let merged = FaceRecord {
779 id: older.id,
780 surface: older.surface.clone(),
781 same_sense: older.same_sense,
782 loops,
783 name: older.name.clone(),
784 };
785 let expected = parameter_space_area(older)? + parameter_space_area(newer)?;
786 let actual = parameter_space_area(&merged)?;
787 let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
788 if (actual - expected).abs() > area_tolerance
789 || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
790 {
791 return Ok(None);
792 }
793 return Ok(Some(merged));
794 }
795
796 if let Some(support) = cosurface_cylinder {
797 let x_reference = {
801 let seed = loops
802 .iter()
803 .flat_map(|loop_record| &loop_record.coedges)
804 .find_map(|coedge| {
805 let edge = edges[&coedge.edge_id];
806 edge.curve.evaluate(edge.t0).ok()
807 })
808 .ok_or("cylinder merge: no boundary sample")?;
809 let delta = seed.sub(support.origin);
810 let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
811 match radial.normalized() {
812 Ok(radial) => radial,
813 Err(_) => return Ok(None),
814 }
815 };
816 let y_reference = support.axis.cross(x_reference);
817 let mut angles = Vec::new();
818 let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
819 for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
820 let edge = edges[&coedge.edge_id];
821 let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
822 let [c0, c1] = curve.domain()?;
823 for index in 0..=8 {
824 let point = curve.evaluate(c0 + (c1 - c0) * index as f64 / 8.0)?;
825 let delta = point.sub(support.origin);
826 let axial = delta.dot(support.axis);
827 axial_range[0] = axial_range[0].min(axial);
828 axial_range[1] = axial_range[1].max(axial);
829 let radial = delta.sub(support.axis.scale(axial));
830 if radial.length() <= support.radius * 1e-6 {
831 continue;
832 }
833 let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
834 if angle < 0.0 {
835 angle += std::f64::consts::TAU;
836 }
837 angles.push(angle);
838 }
839 }
840 if angles.len() < 3 || axial_range[1] - axial_range[0] <= tolerance {
841 return Ok(None);
842 }
843 angles.sort_by(f64::total_cmp);
844 let mut gap_start = angles.len() - 1;
845 let mut largest_gap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
846 for index in 0..angles.len() - 1 {
847 let gap = angles[index + 1] - angles[index];
848 if gap > largest_gap {
849 largest_gap = gap;
850 gap_start = index;
851 }
852 }
853 let span = std::f64::consts::TAU - largest_gap;
854 if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
855 return Ok(None);
856 }
857 let start_angle = angles[(gap_start + 1) % angles.len()];
858 let start_direction = x_reference
859 .scale(start_angle.cos())
860 .add(y_reference.scale(start_angle.sin()));
861 let generatrix = make_line(
862 support
863 .origin
864 .add(support.axis.scale(axial_range[0]))
865 .add(start_direction.scale(support.radius)),
866 support
867 .origin
868 .add(support.axis.scale(axial_range[1]))
869 .add(start_direction.scale(support.radius)),
870 )?;
871 let surface = make_revolution(support.origin, support.axis, &generatrix, span)?;
872 let (u_domain, v_domain) = surface_domains(&surface)?;
873 let sample = surface.evaluate(
874 (u_domain[0] + u_domain[1]) / 2.0,
875 (v_domain[0] + v_domain[1]) / 2.0,
876 )?;
877 let geometric_normal = surface.normal(
878 (u_domain[0] + u_domain[1]) / 2.0,
879 (v_domain[0] + v_domain[1]) / 2.0,
880 )?;
881 let delta = sample.sub(support.origin);
882 let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
883 let radial = match radial.normalized() {
884 Ok(radial) => radial,
885 Err(_) => return Ok(None),
886 };
887 let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
888 let mut projected_loops = reproject_loops(&surface, loops, edges)?;
889 projected_loops.sort_by(|first, second| {
890 let area = |loop_record: &LoopRecord| {
891 parameter_space_area(&FaceRecord {
892 id: older.id,
893 surface: surface.clone(),
894 same_sense,
895 loops: vec![loop_record.clone()],
896 name: None,
897 })
898 .map(f64::abs)
899 };
900 match (area(first), area(second)) {
901 (Ok(a), Ok(b)) => b.total_cmp(&a),
902 _ => std::cmp::Ordering::Equal,
903 }
904 });
905 let merged = FaceRecord {
906 id: older.id,
907 surface,
908 same_sense,
909 loops: projected_loops,
910 name: older.name.clone(),
911 };
912 let area = parameter_space_area(&merged)?;
913 if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
914 return Ok(None);
915 }
916 let expected = face_area(older)? + face_area(newer)?;
919 let actual = face_area(&merged)?;
920 if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
921 return Ok(None);
922 }
923 return Ok(Some(merged));
924 }
925
926 if let Some((support, union)) = cosurface_extrusion {
927 let shift = support.direction.scale(union[0] - support.interval[0]);
930 let step = support.direction.scale(union[1] - union[0]);
931 let rows = support
932 .base
933 .control_points
934 .iter()
935 .map(|control| -> Result<Vec<Vec4>, String> {
936 let point = control.point()?.add(shift);
937 Ok(vec![
938 Vec4::from_point(point, control.w),
939 Vec4::from_point(point.add(step), control.w),
940 ])
941 })
942 .collect::<Result<Vec<_>, _>>()?;
943 let surface = NurbsSurface::new(
944 support.base.degree,
945 1,
946 support.base.knots.clone(),
947 vec![0.0, 0.0, 1.0, 1.0],
948 rows,
949 )?;
950 let (older_u, older_v) = surface_domains(&older.surface)?;
951 let older_mid = older.surface.evaluate(
952 (older_u[0] + older_u[1]) / 2.0,
953 (older_v[0] + older_v[1]) / 2.0,
954 )?;
955 let projection = crate::project_point_to_surface(&surface, older_mid)?;
956 let carrier_normal = surface.normal(projection.u, projection.v)?;
957 let same_sense = carrier_normal.dot(outward_normal(older)?) > 0.0;
958 let mut projected_loops = reproject_loops(&surface, loops, edges)?;
959 projected_loops.sort_by(|first, second| {
960 let area = |loop_record: &LoopRecord| {
961 parameter_space_area(&FaceRecord {
962 id: older.id,
963 surface: surface.clone(),
964 same_sense,
965 loops: vec![loop_record.clone()],
966 name: None,
967 })
968 .map(f64::abs)
969 };
970 match (area(first), area(second)) {
971 (Ok(a), Ok(b)) => b.total_cmp(&a),
972 _ => std::cmp::Ordering::Equal,
973 }
974 });
975 let merged = FaceRecord {
976 id: older.id,
977 surface,
978 same_sense,
979 loops: projected_loops,
980 name: older.name.clone(),
981 };
982 let area = parameter_space_area(&merged)?;
983 if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
984 return Ok(None);
985 }
986 let expected = face_area(older)? + face_area(newer)?;
987 let actual = face_area(&merged)?;
988 if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
989 if std::env::var("BREP_DEBUG_MERGE").is_ok() {
990 eprintln!(
991 "coextrusion {}x{}: area mismatch {actual} vs {expected}",
992 older.id, newer.id
993 );
994 }
995 return Ok(None);
996 }
997 return Ok(Some(merged));
998 }
999
1000 let (u_domain, v_domain) = surface_domains(&older.surface)?;
1001 let origin = older.surface.evaluate(u_domain[0], v_domain[0])?;
1002 let derivatives = older.surface.derivatives(
1003 (u_domain[0] + u_domain[1]) / 2.0,
1004 (v_domain[0] + v_domain[1]) / 2.0,
1005 1,
1006 )?;
1007 let u_axis = derivatives[1][0].normalized()?;
1008 let geometric_normal = derivatives[1][0].cross(derivatives[0][1]).normalized()?;
1009 let v_axis = geometric_normal.cross(u_axis).normalized()?;
1010 let mut bounds_u = [f64::INFINITY, f64::NEG_INFINITY];
1011 let mut bounds_v = [f64::INFINITY, f64::NEG_INFINITY];
1012 for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1013 let edge = edges[&coedge.edge_id];
1014 let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1015 for control in &curve.control_points {
1016 let relative = control.point()?.sub(origin);
1017 let u = relative.dot(u_axis);
1018 let v = relative.dot(v_axis);
1019 bounds_u[0] = bounds_u[0].min(u);
1020 bounds_u[1] = bounds_u[1].max(u);
1021 bounds_v[0] = bounds_v[0].min(v);
1022 bounds_v[1] = bounds_v[1].max(v);
1023 }
1024 }
1025 let extent_u = bounds_u[1] - bounds_u[0];
1026 let extent_v = bounds_v[1] - bounds_v[0];
1027 if extent_u <= tolerance || extent_v <= tolerance {
1028 return Ok(None);
1029 }
1030 let surface_origin = origin
1031 .add(u_axis.scale(bounds_u[0]))
1032 .add(v_axis.scale(bounds_v[0]));
1033 let surface = make_plane(surface_origin, u_axis, v_axis, extent_u, extent_v)?;
1034 let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1035 projected_loops.sort_by(|first, second| {
1036 let area = |loop_record: &LoopRecord| {
1037 parameter_space_area(&FaceRecord {
1038 id: older.id,
1039 surface: surface.clone(),
1040 same_sense: older.same_sense,
1041 loops: vec![loop_record.clone()],
1042 name: None,
1043 })
1044 .map(f64::abs)
1045 };
1046 match (area(first), area(second)) {
1047 (Ok(a), Ok(b)) => b.total_cmp(&a),
1048 _ => std::cmp::Ordering::Equal,
1049 }
1050 });
1051 let merged = FaceRecord {
1052 id: older.id,
1053 surface,
1054 same_sense: older.same_sense,
1055 loops: projected_loops,
1056 name: older.name.clone(),
1057 };
1058 let area = parameter_space_area(&merged)?;
1059 if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1060 return Ok(None);
1061 }
1062 Ok(Some(merged))
1063}
1064
1065fn try_merge_connected_group(
1066 faces: &[FaceRecord],
1067 edges: &HashMap<u64, &EdgeRecord>,
1068 same_carrier_area_tolerance_ratio: f64,
1069) -> Result<Option<(Vec<usize>, FaceRecord)>, String> {
1070 for seed in 0..faces.len() {
1071 let mut component = [seed].into_iter().collect::<HashSet<_>>();
1072 loop {
1073 let mut changed = false;
1074 for candidate in 0..faces.len() {
1075 if component.contains(&candidate)
1076 || faces[candidate].same_sense != faces[seed].same_sense
1077 || !same_surface(&faces[candidate].surface, &faces[seed].surface, 1e-7)
1078 {
1079 continue;
1080 }
1081 let candidate_uses = faces[candidate]
1082 .loops
1083 .iter()
1084 .flat_map(|loop_record| &loop_record.coedges);
1085 let adjacent = candidate_uses.into_iter().any(|candidate_use| {
1086 !edges[&candidate_use.edge_id].degenerate
1087 && component.iter().any(|member| {
1088 faces[*member]
1089 .loops
1090 .iter()
1091 .flat_map(|loop_record| &loop_record.coedges)
1092 .any(|member_use| {
1093 member_use.edge_id == candidate_use.edge_id
1094 && member_use.forward != candidate_use.forward
1095 })
1096 })
1097 });
1098 if adjacent {
1099 component.insert(candidate);
1100 changed = true;
1101 }
1102 }
1103 if !changed {
1104 break;
1105 }
1106 }
1107 if component.len() < 2 {
1108 continue;
1109 }
1110
1111 let mut uses_by_edge = HashMap::<u64, Vec<CoedgeRecord>>::default();
1112 for index in &component {
1113 for coedge in faces[*index]
1114 .loops
1115 .iter()
1116 .flat_map(|loop_record| &loop_record.coedges)
1117 {
1118 uses_by_edge
1119 .entry(coedge.edge_id)
1120 .or_default()
1121 .push(coedge.clone());
1122 }
1123 }
1124 if uses_by_edge.values().any(|uses| uses.len() > 2) {
1125 continue;
1126 }
1127 let internal = uses_by_edge
1128 .iter()
1129 .filter_map(|(edge_id, uses)| {
1130 (uses.len() == 2
1131 && !edges[edge_id].degenerate
1132 && uses[0].forward != uses[1].forward)
1133 .then_some(*edge_id)
1134 })
1135 .collect::<HashSet<_>>();
1136 let mut remaining = uses_by_edge
1137 .into_iter()
1138 .filter(|(edge_id, _)| !internal.contains(edge_id))
1139 .flat_map(|(_, uses)| uses)
1140 .collect::<Vec<_>>();
1141 let maximum_cycle_length = remaining.len();
1142 let mut loops = Vec::new();
1143 let mut failed = false;
1144 while !remaining.is_empty() {
1145 let first = remaining.remove(0);
1146 let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
1147 let mut cycle = vec![first];
1148 while end != start {
1149 let matches = remaining
1150 .iter()
1151 .enumerate()
1152 .filter_map(|(index, coedge)| {
1153 (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end)
1154 .then_some(index)
1155 })
1156 .collect::<Vec<_>>();
1157 if matches.len() != 1 {
1158 failed = true;
1159 break;
1160 }
1161 let next = remaining.remove(matches[0]);
1162 end = traversal_vertices(edges[&next.edge_id], &next).1;
1163 cycle.push(next);
1164 if cycle.len() > maximum_cycle_length {
1165 failed = true;
1166 break;
1167 }
1168 }
1169 if failed {
1170 break;
1171 }
1172 loops.push(LoopRecord {
1173 id: loops.len() as u64 + 1,
1174 coedges: cycle,
1175 });
1176 }
1177 if failed || loops.is_empty() {
1178 continue;
1179 }
1180 loops.sort_by(|first, second| {
1181 let area = |loop_record: &LoopRecord| {
1182 parameter_space_area(&FaceRecord {
1183 id: faces[seed].id,
1184 surface: faces[seed].surface.clone(),
1185 same_sense: faces[seed].same_sense,
1186 loops: vec![loop_record.clone()],
1187 name: None,
1188 })
1189 .map(f64::abs)
1190 };
1191 match (area(first), area(second)) {
1192 (Ok(a), Ok(b)) => b.total_cmp(&a),
1193 _ => std::cmp::Ordering::Equal,
1194 }
1195 });
1196 let merged = FaceRecord {
1197 id: faces[seed].id,
1198 surface: faces[seed].surface.clone(),
1199 same_sense: faces[seed].same_sense,
1200 loops,
1201 name: faces[seed].name.clone(),
1202 };
1203 let expected = component
1204 .iter()
1205 .map(|index| parameter_space_area(&faces[*index]))
1206 .collect::<Result<Vec<_>, _>>()?
1207 .into_iter()
1208 .sum::<f64>();
1209 let actual = parameter_space_area(&merged)?;
1210 let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
1211 if (actual - expected).abs() > area_tolerance
1212 || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1213 {
1214 continue;
1215 }
1216 let mut indices = component.into_iter().collect::<Vec<_>>();
1217 indices.sort_unstable();
1218 return Ok(Some((indices, merged)));
1219 }
1220 Ok(None)
1221}
1222
1223fn merge_same_surface_faces_impl(
1228 solid: &BrepSolid,
1229 tolerance: f64,
1230 validate: bool,
1231 same_carrier_area_tolerance_ratio: f64,
1232) -> Result<BrepSolid, String> {
1233 let mut result = solid.clone();
1234 for shell in &mut result.shells {
1235 loop {
1236 let edges = result
1237 .edges
1238 .iter()
1239 .map(|edge| (edge.id, edge))
1240 .collect::<HashMap<_, _>>();
1241 if !validate {
1242 if let Some((indices, merged)) = try_merge_connected_group(
1243 &shell.faces,
1244 &edges,
1245 same_carrier_area_tolerance_ratio,
1246 )? {
1247 let first = indices[0];
1248 shell.faces[first] = merged;
1249 for index in indices.into_iter().skip(1).rev() {
1250 shell.faces.remove(index);
1251 }
1252 continue;
1253 }
1254 }
1255 let mut accepted = None;
1256 'pairs: for first in 0..shell.faces.len() {
1257 for second in first + 1..shell.faces.len() {
1258 if let Some(merged) = try_merge_pair(
1259 &shell.faces[first],
1260 &shell.faces[second],
1261 &edges,
1262 tolerance,
1263 same_carrier_area_tolerance_ratio,
1264 )? {
1265 accepted = Some((first, second, merged));
1266 break 'pairs;
1267 }
1268 }
1269 }
1270 let Some((first, second, merged)) = accepted else {
1271 break;
1272 };
1273 shell.faces[first] = merged;
1274 shell.faces.remove(second);
1275 }
1276 }
1277 let used_edges = result
1278 .shells
1279 .iter()
1280 .flat_map(|shell| &shell.faces)
1281 .flat_map(|face| &face.loops)
1282 .flat_map(|loop_record| &loop_record.coedges)
1283 .map(|coedge| coedge.edge_id)
1284 .collect::<HashSet<_>>();
1285 result.edges.retain(|edge| used_edges.contains(&edge.id));
1286 let used_vertices = result
1287 .edges
1288 .iter()
1289 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1290 .collect::<HashSet<_>>();
1291 result
1292 .vertices
1293 .retain(|vertex| used_vertices.contains(&vertex.id));
1294 if !validate {
1295 return Ok(result);
1296 }
1297 if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1298 let entry_faces: i64 = solid.shells.iter().map(|s| s.faces.len() as i64).sum();
1299 let entry_holes: i64 = solid
1300 .shells
1301 .iter()
1302 .flat_map(|s| &s.faces)
1303 .map(|f| f.loops.len().saturating_sub(1) as i64)
1304 .sum();
1305 let entry_edges = solid.edges.iter().filter(|e| !e.degenerate).count() as i64;
1306 let entry_used: std::collections::HashSet<u64> = solid
1307 .shells
1308 .iter()
1309 .flat_map(|s| &s.faces)
1310 .flat_map(|f| &f.loops)
1311 .flat_map(|l| &l.coedges)
1312 .map(|c| c.edge_id)
1313 .collect();
1314 eprintln!(
1315 "merge entry census: V={} E={entry_edges} (coedge-referenced {}) F={entry_faces} H={entry_holes} S={} stored_genus={}",
1316 solid.vertices.len(),
1317 entry_used.len(),
1318 solid.shells.len(),
1319 solid.genus
1320 );
1321 }
1322 let face_count = result
1323 .shells
1324 .iter()
1325 .map(|shell| shell.faces.len() as i64)
1326 .sum::<i64>();
1327 let hole_count = result
1328 .shells
1329 .iter()
1330 .flat_map(|shell| &shell.faces)
1331 .map(|face| face.loops.len().saturating_sub(1) as i64)
1332 .sum::<i64>();
1333 let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
1334 let live_vertices: std::collections::HashSet<u64> = result
1340 .edges
1341 .iter()
1342 .filter(|edge| !edge.degenerate)
1343 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1344 .collect();
1345 let vertex_count = result
1346 .vertices
1347 .iter()
1348 .filter(|vertex| live_vertices.contains(&vertex.id))
1349 .count() as i64;
1350 let euler = vertex_count - edge_count + face_count - hole_count;
1351 let numerator = result.shells.len() as i64 * 2 - euler;
1352 let has_degenerate = result.edges.iter().any(|edge| edge.degenerate);
1360 if numerator < 0 || numerator % 2 != 0 {
1361 if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1362 eprintln!(
1363 "merge euler reject: V={vertex_count} E={edge_count} F={face_count} H={hole_count} S={} euler={euler} numerator={numerator} degenerate={has_degenerate}",
1364 result.shells.len()
1365 );
1366 }
1367 if !has_degenerate
1368 || std::env::var("BREP_MERGE_DEGENERATE_EULER_STRICT").as_deref() == Ok("1")
1369 {
1370 return Err("merge_same_surface_faces produced non-integral genus".into());
1371 }
1372 } else {
1373 result.genus = numerator / 2;
1374 }
1375 let issues = result.validate();
1376 if !issues.is_empty() {
1377 return Err(format!(
1378 "merge_same_surface_faces produced invalid topology: {issues:?}"
1379 ));
1380 }
1381 Ok(result)
1382}
1383
1384pub fn merge_same_surface_faces(solid: &BrepSolid, tolerance: f64) -> Result<BrepSolid, String> {
1385 merge_same_surface_faces_impl(solid, tolerance, true, 1e-7)
1386}
1387
1388pub(crate) fn merge_same_surface_faces_open(
1389 solid: &BrepSolid,
1390 tolerance: f64,
1391) -> Result<BrepSolid, String> {
1392 merge_same_surface_faces_impl(solid, tolerance, false, 5e-3)
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397 use crate::{make_box_brep, offset_shell, solid_mass_properties, Vec3};
1398
1399 #[test]
1400 fn offset_shell_coalesces_opening_wall_fragments_without_changing_volume() {
1401 let source = make_box_brep(Vec3::default(), 4.0, 4.0, 4.0).unwrap();
1402 let opening = source.shells[0]
1403 .faces
1404 .iter()
1405 .find(|face| {
1406 let point = face.surface.evaluate(0.5, 0.5).unwrap();
1407 (point.z - 4.0).abs() < 1e-9
1408 })
1409 .unwrap();
1410 let shell = offset_shell(&source, &[opening.id], 0.5).unwrap().solid;
1411 assert_eq!(shell.shells[0].faces.len(), 11);
1412 assert!((solid_mass_properties(&shell).unwrap().volume - 32.5).abs() < 1e-6);
1413 }
1414}