use super::*;
const RIM_STATION_SAMPLES: usize = 16;
fn rim_iso_station(surface: &NurbsSurface, rim: &NurbsCurve, tolerance: f64) -> Option<f64> {
let [t0, t1] = rim.domain().ok()?;
let [v0, v1] = surface.domain_v().ok()?;
let v_span = (v1 - v0).abs().max(1e-12);
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
let mut total = 0.0f64;
for index in 0..RIM_STATION_SAMPLES {
let t = t0 + (t1 - t0) * index as f64 / RIM_STATION_SAMPLES as f64;
let point = rim.evaluate(t).ok()?;
let projection = crate::project_point_to_surface(surface, point).ok()?;
if projection.distance > tolerance {
return None;
}
low = low.min(projection.v);
high = high.max(projection.v);
total += projection.v;
}
if high - low > 1e-6 * v_span {
return None;
}
Some((total / RIM_STATION_SAMPLES as f64).clamp(v0.min(v1), v0.max(v1)))
}
fn circle_through(a: Vec3, b: Vec3, c: Vec3) -> Option<(Vec3, Vec3, f64)> {
let u = b.sub(a);
let v = c.sub(a);
let n = u.cross(v);
let n_squared = n.dot(n);
if n_squared <= 1e-24 {
return None;
}
let offset = v
.scale(u.dot(u))
.sub(u.scale(v.dot(v)))
.cross(n)
.scale(0.5 / n_squared);
let center = a.add(offset);
Some((center, n.normalized().ok()?, a.sub(center).length()))
}
fn align_closed_rim_origin(
curve: &NurbsCurve,
seam: Vec3,
tolerance: f64,
op: &str,
) -> Result<NurbsCurve, String> {
const SAMPLES: usize = 16;
let [t0, t1] = curve.domain()?;
let start = curve.evaluate(t0)?;
let crossing = project_point_to_curve(curve, seam)?;
let target = curve.evaluate(crossing.u)?;
if start.sub(target).length() <= tolerance {
return Ok(curve.clone());
}
let mut samples = Vec::with_capacity(SAMPLES);
for index in 0..SAMPLES {
samples.push(curve.evaluate(t0 + (t1 - t0) * index as f64 / SAMPLES as f64)?);
}
let off_seam = start.sub(target).length();
let decline = || {
format!(
"{op}: the healed rim starts {off_seam:.3e} from the neighbour's seam and is not \
a circle that can be re-origined there — refusing rather than binding a closed \
rim to a vertex off the seam (deferred)"
)
};
let (center, normal, radius) =
circle_through(samples[0], samples[SAMPLES / 3], samples[2 * SAMPLES / 3])
.ok_or_else(decline)?;
for sample in &samples {
let radial = sample.sub(center);
if (radial.length() - radius).abs() > tolerance || radial.dot(normal).abs() > tolerance {
return Err(decline());
}
}
let radial = target.sub(center);
let x_axis = radial.sub(normal.scale(radial.dot(normal))).normalized()?;
let y_axis = normal.cross(x_axis);
let rebuilt = crate::make_arc(center, x_axis, y_axis, radius, 0.0, std::f64::consts::TAU)?;
for sample in &samples {
if project_point_to_curve(&rebuilt, *sample)?.distance > tolerance {
return Err(decline());
}
}
Ok(rebuilt)
}
pub(super) fn loop_signed_area(face: &FaceRecord, loop_index: usize) -> Result<f64, String> {
parameter_space_area(&FaceRecord {
id: face.id,
surface: face.surface.clone(),
same_sense: face.same_sense,
loops: vec![face.loops[loop_index].clone()],
name: None,
})
}
fn rim_hole_loop(
solid: &BrepSolid,
neighbour_id: u64,
rim: u64,
op: &str,
) -> Result<Option<(usize, usize, usize)>, String> {
let (shell, face_pos) =
find_face(solid, neighbour_id).ok_or_else(|| format!("{op}: missing face {neighbour_id}"))?;
let face = &solid.shells[shell].faces[face_pos];
if face.loops.len() < 2 {
return Ok(None);
}
let mut carrying = face
.loops
.iter()
.enumerate()
.filter(|(_, loop_record)| {
loop_record
.coedges
.iter()
.any(|coedge| coedge.edge_id == rim)
})
.map(|(index, _)| index);
let Some(loop_index) = carrying.next() else {
return Ok(None);
};
if carrying.next().is_some() || face.loops[loop_index].coedges.len() != 1 {
return Ok(None);
}
let mut areas = Vec::with_capacity(face.loops.len());
for index in 0..face.loops.len() {
areas.push(loop_signed_area(face, index)?);
}
let host = (0..areas.len())
.max_by(|a, b| areas[*a].abs().total_cmp(&areas[*b].abs()))
.expect("at least two loops");
if host == loop_index || areas[host] * areas[loop_index] >= 0.0 {
return Ok(None);
}
Ok(Some((shell, face_pos, loop_index)))
}
fn through_wall_hole_loops(
solid: &BrepSolid,
rims: &[u64],
neighbour_ids: &[u64; 2],
op: &str,
) -> Result<Option<[(usize, usize, usize); 2]>, String> {
let mut found = Vec::with_capacity(2);
for (index, rim) in rims.iter().enumerate() {
match rim_hole_loop(solid, neighbour_ids[index], *rim, op)? {
Some(position) => found.push(position),
None => return Ok(None),
}
}
Ok(Some([found[0], found[1]]))
}
fn blind_pocket_floor(
solid: &BrepSolid,
rims: &[u64],
neighbour_ids: &[u64; 2],
op: &str,
) -> Result<Option<u64>, String> {
for near in 0..2usize {
let far = 1 - near;
if rim_hole_loop(solid, neighbour_ids[near], rims[near], op)?.is_none() {
continue;
}
let (shell, face_pos) = find_face(solid, neighbour_ids[far])
.ok_or_else(|| format!("{op}: missing face {}", neighbour_ids[far]))?;
let floor = &solid.shells[shell].faces[face_pos];
if floor.loops.len() == 1
&& floor.loops[0].coedges.len() == 1
&& floor.loops[0].coedges[0].edge_id == rims[far]
{
return Ok(Some(neighbour_ids[far]));
}
}
Ok(None)
}
pub(super) fn faces_are_connected(faces: &[FaceRecord]) -> bool {
if faces.is_empty() {
return true;
}
let mut by_edge: HashMap<u64, Vec<usize>> = HashMap::default();
for (index, face) in faces.iter().enumerate() {
for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
by_edge.entry(coedge.edge_id).or_default().push(index);
}
}
let mut seen: HashSet<usize> = HashSet::default();
let mut stack = vec![0usize];
seen.insert(0);
while let Some(index) = stack.pop() {
for coedge in faces[index]
.loops
.iter()
.flat_map(|loop_record| &loop_record.coedges)
{
for neighbour in by_edge.get(&coedge.edge_id).into_iter().flatten() {
if seen.insert(*neighbour) {
stack.push(*neighbour);
}
}
}
}
seen.len() == faces.len()
}
fn cap_through_wall(
solid: &BrepSolid,
shell_index: usize,
face_index: usize,
boundary: &[(u64, bool)],
holes: [(usize, usize, usize); 2],
op: &str,
) -> Result<BrepSolid, String> {
let mut solid = solid.clone();
let wall_edges: HashSet<u64> = boundary.iter().map(|(edge_id, _)| *edge_id).collect();
for (shell, face_pos, loop_index) in holes {
solid.shells[shell].faces[face_pos].loops.remove(loop_index);
}
solid.shells[shell_index].faces.remove(face_index);
solid.edges.retain(|edge| !wall_edges.contains(&edge.id));
let used: HashSet<u64> = solid
.edges
.iter()
.flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
.collect();
solid.vertices.retain(|vertex| used.contains(&vertex.id));
if !faces_are_connected(&solid.shells[shell_index].faces) {
return Err(format!(
"{op}: the deleted wall joins two otherwise separate parts of the body — \
capping it would sever the solid, which this operation cannot represent \
(deferred)"
));
}
solid.genus -= 1;
if solid.genus < 0 {
return Err(format!(
"{op}: capping the wall leaves genus {}, so the solid's stated genus did not \
account for the through feature it carries (deferred)",
solid.genus
));
}
let issues = solid.validate();
if !issues.is_empty() {
return Err(format!("{op}: through-wall cap failed validation: {issues:?}"));
}
Ok(solid)
}
pub(super) fn heal_closed_transition(
solid: &BrepSolid,
shell_index: usize,
face_index: usize,
boundary: &[(u64, bool)],
) -> Result<BrepSolid, String> {
let op = "delete_face_and_heal";
let mut solid = solid.clone();
let scale = solid_model_scale(&solid);
let tolerance = (scale * 1e-6).max(1e-9);
let face_id = solid.shells[shell_index].faces[face_index].id;
let mut counts: HashMap<u64, usize> = HashMap::default();
for (edge_id, _) in boundary {
*counts.entry(*edge_id).or_default() += 1;
}
let seam_id = *counts
.iter()
.find(|(_, count)| **count == 2)
.map(|(id, _)| id)
.ok_or_else(|| format!("{op}: closed transition without a doubled seam"))?;
let rims: Vec<u64> = boundary
.iter()
.map(|(edge_id, _)| *edge_id)
.filter(|edge_id| *edge_id != seam_id)
.collect();
if rims.len() != 2 || rims[0] == rims[1] {
return Err(format!("{op}: closed transition needs exactly two rims"));
}
for rim in &rims {
let edge = solid
.edges
.iter()
.find(|edge| edge.id == *rim)
.ok_or_else(|| format!("{op}: missing rim {rim}"))?;
if edge.start_vertex_id != edge.end_vertex_id {
return Err(format!("{op}: rim {rim} is not closed"));
}
}
let mut neighbour_ids = [0u64; 2];
for (index, rim) in rims.iter().enumerate() {
neighbour_ids[index] = other_face_of_edge(&solid, *rim, face_id)?;
}
if neighbour_ids[0] == neighbour_ids[1] {
return Err(format!(
"{op}: both rims border the same neighbour (deferred)"
));
}
if let Some(holes) = through_wall_hole_loops(&solid, &rims, &neighbour_ids, op)? {
return cap_through_wall(&solid, shell_index, face_index, boundary, holes, op);
}
let blind_floor = blind_pocket_floor(&solid, &rims, &neighbour_ids, op)?;
let deferral = |reason: &str| match blind_floor {
Some(floor) => format!(
"{op}: the wall's far rim is the whole boundary of face {floor} — this is a \
blind pocket, and its wall and floor are one PATCH: select that face too \
and the whole pocket caps in a single delete"
),
None => format!("{op}: {reason} (deferred)"),
};
let mut strip_points: Vec<Vec3> = Vec::new();
for rim in &rims {
let edge = solid
.edges
.iter()
.find(|edge| edge.id == *rim)
.ok_or_else(|| format!("{op}: missing rim {rim}"))?;
for sample in 0..8 {
let t = edge.t0 + (edge.t1 - edge.t0) * sample as f64 / 8.0;
strip_points.push(edge.curve.evaluate(t)?);
}
}
for &neighbour_id in &neighbour_ids {
extend_ruled_neighbour_over(&mut solid, neighbour_id, &strip_points, tolerance)?;
}
let neighbour_surface = |id: u64, solid: &BrepSolid| -> Result<NurbsSurface, String> {
let (shell, face) =
find_face(solid, id).ok_or_else(|| format!("{op}: missing face {id}"))?;
Ok(solid.shells[shell].faces[face].surface.clone())
};
let surface_a = neighbour_surface(neighbour_ids[0], &solid)?;
let surface_b = neighbour_surface(neighbour_ids[1], &solid)?;
if std::env::var("BREP_DEBUG_HEAL").is_ok() {
eprintln!(
"HEAL neighbours {:?} analytic a={:?} b={:?}",
neighbour_ids,
surface_a.analytic().map(std::mem::discriminant),
surface_b.analytic().map(std::mem::discriminant)
);
}
let curves = intersect_analytic_pair(&surface_a, &surface_b, tolerance)
.ok_or_else(|| deferral("neighbours are not a recognized analytic pair"))?;
let closed: Vec<_> = curves
.into_iter()
.filter(|curve| {
let [t0, t1] = curve.domain().unwrap_or([0.0, 1.0]);
curve
.evaluate(t0)
.and_then(|a| curve.evaluate(t1).map(|b| a.sub(b).length()))
.map(|gap| gap <= tolerance)
.unwrap_or(false)
})
.collect();
let strip_centre = {
let face = &solid.shells[shell_index].faces[face_index];
face.surface.evaluate(0.5, 0.5)?
};
let new_curve = closed
.into_iter()
.min_by(|a, b| {
let mid = |curve: &crate::NurbsCurve| {
let [t0, t1] = curve.domain().unwrap_or([0.0, 1.0]);
curve.evaluate(0.5 * (t0 + t1)).unwrap_or_default()
};
mid(a)
.sub(strip_centre)
.length()
.total_cmp(&mid(b).sub(strip_centre).length())
})
.ok_or_else(|| deferral("neighbours do not re-intersect in a closed rim"))?;
let seam_target: Option<Vec3> = {
let mut wanted: Option<Vec3> = None;
for (index, rim) in rims.iter().enumerate() {
let neighbour_id = neighbour_ids[index];
let (ns, nf) = find_face(&solid, neighbour_id)
.ok_or_else(|| format!("{op}: missing neighbour {neighbour_id}"))?;
if matches!(
solid.shells[ns].faces[nf].surface.analytic(),
Some(AnalyticSurface::Plane { .. })
) {
continue;
}
let edge = solid
.edges
.iter()
.find(|edge| edge.id == *rim)
.ok_or_else(|| format!("{op}: missing rim {rim}"))?;
let seam_point = edge.curve.evaluate(edge.t0)?;
match wanted {
None => wanted = Some(seam_point),
Some(known) if known.sub(seam_point).length() <= tolerance => {}
Some(_) => {
return Err(format!(
"{op}: the strip's two curved neighbours put their seams in different \
places — the healed rim cannot start on both (deferred)"
))
}
}
}
wanted
};
let new_curve = match seam_target {
Some(seam) => align_closed_rim_origin(&new_curve, seam, tolerance, op)?,
None => new_curve,
};
let mut next_id = max_topology_id(&solid) + 1;
let mut alloc = || {
let value = next_id;
next_id += 1;
value
};
let [nt0, nt1] = new_curve.domain()?;
let new_vertex_id = alloc();
let new_point = new_curve.evaluate(nt0)?;
solid.vertices.push(VertexRecord {
id: new_vertex_id,
point: new_point,
});
let new_edge_id = alloc();
solid.edges.push(EdgeRecord {
id: new_edge_id,
curve: new_curve.clone(),
t0: nt0,
t1: nt1,
start_vertex_id: new_vertex_id,
end_vertex_id: new_vertex_id,
degenerate: false,
name: None,
});
for (index, rim) in rims.iter().enumerate() {
let neighbour_id = neighbour_ids[index];
let (shell, face_pos) = find_face(&solid, neighbour_id)
.ok_or_else(|| format!("{op}: missing neighbour {neighbour_id}"))?;
let face = &solid.shells[shell].faces[face_pos];
let old_coedge = face
.loops
.iter()
.flat_map(|loop_record| &loop_record.coedges)
.find(|coedge| coedge.edge_id == *rim)
.ok_or_else(|| format!("{op}: neighbour lost its rim coedge"))?;
let old_forward = old_coedge.forward;
let old_edge = solid
.edges
.iter()
.find(|edge| edge.id == *rim)
.ok_or_else(|| format!("{op}: missing rim {rim}"))?
.clone();
let quarter = |curve: &crate::NurbsCurve, forward: bool| -> Result<Vec3, String> {
let [t0, t1] = curve.domain()?;
let f = if forward { 0.25 } else { 0.75 };
curve.evaluate(t0 + (t1 - t0) * f)
};
let old_quarter = quarter(&old_edge.curve, old_forward)?;
let forward_gap = quarter(&new_curve, true)?.sub(old_quarter).length();
let backward_gap = quarter(&new_curve, false)?.sub(old_quarter).length();
let new_forward = forward_gap <= backward_gap;
let is_plane = matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. }));
if is_plane {
let plane = plane_of_surface(&face.surface, tolerance, op)?;
let face = &mut solid.shells[shell].faces[face_pos];
for coedge in face
.loops
.iter_mut()
.flat_map(|loop_record| &mut loop_record.coedges)
{
if coedge.edge_id == *rim {
coedge.edge_id = new_edge_id;
coedge.forward = new_forward;
}
}
let edges_by_id: HashMap<u64, EdgeRecord> = solid
.edges
.iter()
.map(|edge| (edge.id, edge.clone()))
.collect();
let face = &mut solid.shells[shell].faces[face_pos];
retrim_planar_face(face, &plane, &edges_by_id, scale, op)?;
continue;
}
let mut ruled_arm = false;
let v_new = match face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { frame, height, .. }) => {
ruled_arm = true;
let axial = new_point.sub(frame.origin).dot(frame.axis);
if !(-tolerance..=height + tolerance).contains(&axial) {
return Err(format!(
"{op}: healed rim escapes the extended carrier \
(station {axial:.6} of {height:.6})"
));
}
(axial / *height).clamp(0.0, 1.0)
}
Some(_) => rim_iso_station(&face.surface, &new_curve, tolerance).ok_or_else(|| {
format!(
"{op}: the healed rim is not a `v = const` iso-curve of neighbour \
{neighbour_id}'s carrier — refusing rather than binding it to a \
parameter line it does not follow (deferred)"
)
})?,
None => {
return Err(format!(
"{op}: curved neighbour {neighbour_id} is a free-form surface (deferred)"
))
}
};
let [u_low, u_high] = face.surface.domain_u()?;
let rim_start = crate::project_point_to_surface(&face.surface, new_point)?;
let u_span = (u_high - u_low).abs().max(1e-12);
let at_u_end = (rim_start.u - u_low).abs() <= 1e-6 * u_span
|| (rim_start.u - u_high).abs() <= 1e-6 * u_span;
if !at_u_end {
return Err(format!(
"{op}: the healed rim starts at u={:.6} of neighbour {neighbour_id}'s [{u_low:.6}, {u_high:.6}] domain, not at its seam — a whole-domain iso pcurve would trace the rim from the wrong azimuth (deferred)",
rim_start.u
));
}
let ascending = if ruled_arm {
new_forward
} else {
let [rim_t0, rim_t1] = new_curve.domain()?;
let fraction = if new_forward { 0.25 } else { 0.75 };
let quarter = new_curve.evaluate(rim_t0 + (rim_t1 - rim_t0) * fraction)?;
let quarter_u = crate::project_point_to_surface(&face.surface, quarter)?.u;
(quarter_u - u_low).abs() < (quarter_u - u_high).abs()
};
let face = &mut solid.shells[shell].faces[face_pos];
for coedge in face
.loops
.iter_mut()
.flat_map(|loop_record| &mut loop_record.coedges)
{
if coedge.edge_id == *rim {
coedge.edge_id = new_edge_id;
coedge.forward = new_forward;
let (from_u, to_u) = if ascending {
(u_low, u_high)
} else {
(u_high, u_low)
};
coedge.pcurve =
make_line(Vec3::new(from_u, v_new, 0.0), Vec3::new(to_u, v_new, 0.0))?;
}
}
}
let mut collapsed: HashSet<u64> = HashSet::default();
for rim in &rims {
if let Some(edge) = solid.edges.iter().find(|edge| edge.id == *rim) {
collapsed.insert(edge.start_vertex_id);
}
}
let strip_edges: HashSet<u64> = boundary.iter().map(|(edge_id, _)| *edge_id).collect();
let mut extended_edges: HashMap<u64, (bool, bool)> = HashMap::default();
let mut widened_edges: HashSet<u64> = HashSet::default();
for edge in &mut solid.edges {
if strip_edges.contains(&edge.id) || edge.id == new_edge_id {
continue;
}
let start_hit = collapsed.contains(&edge.start_vertex_id);
let end_hit = collapsed.contains(&edge.end_vertex_id);
if !(start_hit || end_hit) {
continue;
}
if start_hit {
edge.start_vertex_id = new_vertex_id;
}
if end_hit {
edge.end_vertex_id = new_vertex_id;
}
if edge.curve.degree == 1 && edge.curve.control_points.len() == 2 {
let anchor = if start_hit {
edge.curve.evaluate(edge.t1)?
} else {
edge.curve.evaluate(edge.t0)?
};
let (from, to) = if start_hit {
(new_point, anchor)
} else {
(anchor, new_point)
};
edge.curve = make_line(from, to)?;
[edge.t0, edge.t1] = edge.curve.domain()?;
} else {
let projection = project_point_to_curve(&edge.curve, new_point)?;
if projection.distance > tolerance {
return Err(format!(
"{op}: curved seam edge {} does not reach the healed rim (nearest \
point {:.3e} away) — refusing rather than replacing it with a straight \
chord (deferred)",
edge.id, projection.distance
));
}
if start_hit {
edge.t0 = projection.u;
} else {
edge.t1 = projection.u;
}
let [d0, d1] = edge.curve.domain()?;
if edge.t1 - edge.t0 <= 1e-9 * (d1 - d0).max(1e-12) {
return Err(format!(
"{op}: healing would collapse curved seam edge {} to zero length",
edge.id
));
}
widened_edges.insert(edge.id);
}
extended_edges.insert(edge.id, (start_hit, end_hit));
}
if !widened_edges.is_empty() {
let edges_by_id: HashMap<u64, EdgeRecord> = solid
.edges
.iter()
.map(|edge| (edge.id, edge.clone()))
.collect();
for shell in &mut solid.shells {
for face in &mut shell.faces {
refit_touched_pcurves(face, &edges_by_id, &widened_edges, false, tolerance, op)?;
}
}
}
for shell in &mut solid.shells {
for face in &mut shell.faces {
let station = match face.surface.analytic() {
Some(AnalyticSurface::RuledRevolution { frame, height, .. }) => {
Some((new_point.sub(frame.origin).dot(frame.axis) / height).clamp(0.0, 1.0))
}
_ => None,
};
let Some(v_new) = station else { continue };
for coedge in face
.loops
.iter_mut()
.flat_map(|loop_record| &mut loop_record.coedges)
{
if widened_edges.contains(&coedge.edge_id) {
continue; }
let Some(&(start_moved, end_moved)) = extended_edges.get(&coedge.edge_id) else {
continue;
};
if coedge.pcurve.degree != 1 || coedge.pcurve.control_points.len() != 2 {
return Err(format!(
"{op}: extended edge has a non-line pcurve (deferred)"
));
}
let start_index = if coedge.forward { 0 } else { 1 };
if start_moved {
let control = &mut coedge.pcurve.control_points[start_index];
control.y = v_new * control.w;
}
if end_moved {
let control = &mut coedge.pcurve.control_points[1 - start_index];
control.y = v_new * control.w;
}
}
}
}
solid.shells[shell_index].faces.remove(face_index);
solid.edges.retain(|edge| !strip_edges.contains(&edge.id));
let used: HashSet<u64> = solid
.edges
.iter()
.flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
.collect();
solid.vertices.retain(|vertex| used.contains(&vertex.id));
let issues = solid.validate();
if !issues.is_empty() {
return Err(format!(
"{op}: closed-transition heal failed validation: {issues:?}"
));
}
Ok(solid)
}