use super::*;
use crate::offset_retrim::plane_of_surface;
use crate::topology::ShellRecord;
use crate::{
boolean_operation, make_plane, BooleanOperation, BooleanOptions,
};
use crate::curve::parameter_line;
use crate::{KernelRefusal, KernelStage, OrRefuse};
const OP: &str = "move_faces(re-cut)";
#[derive(Clone, Copy, Debug)]
struct HalfSpace {
normal: Vec3,
offset: f64,
}
pub fn recut_moved_planes(
solid: &BrepSolid,
moved: &[u64],
translation: Vec3,
) -> Result<BrepSolid, KernelRefusal> {
recut_moved_planes_rigid(solid, moved, None, translation)
}
struct Motion {
rotation: Option<AffineTransform>,
translation: Vec3,
}
impl Motion {
fn point(&self, point: Vec3) -> Vec3 {
match &self.rotation {
None => point.add(self.translation),
Some(rotation) => rotation.point(point).add(self.translation),
}
}
fn plane(&self, normal: Vec3, offset: f64) -> (Vec3, f64) {
match &self.rotation {
None => (normal, offset + self.translation.dot(normal)),
Some(rotation) => {
let turned = super::face_rotate::rotate_direction(rotation, normal);
(turned, turned.dot(self.point(normal.scale(offset))))
}
}
}
}
pub fn recut_moved_planes_rigid(
solid: &BrepSolid,
moved: &[u64],
rotation: Option<(Vec3, Vec3, f64)>,
translation: Vec3,
) -> Result<BrepSolid, KernelRefusal> {
let scale = solid_model_scale(solid);
let tolerance = (scale * 1e-7).max(1e-9);
let moved_set: HashSet<u64> = moved.iter().copied().collect();
if moved_set.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"selection",
format!("{OP}: no face was selected"),
));
}
let motion = Motion {
rotation: match rotation {
None => None,
Some((point, axis, angle)) => {
let axis = axis.normalized().map_err(|_| {
KernelRefusal::input(
KernelStage::Collect,
"rotation_axis",
format!("{OP}: the rotation axis has zero length"),
)
})?;
Some(
super::face_rotate::rotation_about(point, axis, angle)
.or_input(KernelStage::Collect, "rotation")?,
)
}
},
translation,
};
let mut carriers: Vec<(u64, Vec3, f64)> = Vec::new();
for &id in &moved_set {
let face = face_by_id(solid, id)?;
let plane = plane_of_surface(&face.surface, tolerance.max(1e-9) * 10.0, OP).map_err(
|error| {
KernelRefusal::unsupported(
KernelStage::Classify,
"curved_carrier",
format!(
"{error} — this road sweeps a PLANE's slab, so face {id}{} cannot take it",
named(face)
),
)
},
)?;
let normal = if face.same_sense {
plane.normal
} else {
plane.normal.scale(-1.0)
};
carriers.push((id, normal, normal.dot(plane.origin)));
}
carriers.sort_by_key(|entry| entry.0);
let bounds = solid_bounds(solid)?;
let bounds = if motion.rotation.is_none() {
bounds
} else {
solid.vertices.iter().fold(bounds, |(low, high), vertex| {
let point = motion.point(vertex.point);
(
Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z)),
Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z)),
)
})
};
let margin = translation.length() + scale.max(1.0);
let clip = box_half_spaces(bounds, margin);
let motion_floor = (scale * 1e-9).max(1e-12);
let edge_band = (scale * 1e-7).max(1e-9);
let mut removing: Vec<BrepSolid> = Vec::new();
let mut adding: Vec<BrepSolid> = Vec::new();
for &(id, normal, offset) in &carriers {
let face = face_by_id(solid, id)?;
let (moved_normal, moved_offset) = motion.plane(normal, offset);
let facing = moved_normal.dot(normal);
if motion.rotation.is_some() && facing <= 1e-6 {
return Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"turned_past_right_angle",
format!(
"{OP}: the motion turns face {id}{} through a right angle or more, so its \
new carrier does not face the way the old one did and there is no wedge \
between them for a tool to fill; refusing",
named(face)
),
));
}
let unturned = moved_normal.sub(normal).length() <= 1e-12;
let translation_delta = translation.dot(normal);
if motion.rotation.is_none() && translation_delta.abs() <= motion_floor {
continue;
}
let mut neighbour_planes: HashMap<u64, ((Vec3, f64), (Vec3, f64))> = HashMap::default();
let mut tangent_bands: HashSet<u64> = HashSet::default();
let polygon = trim_polygon(solid, face, normal, edge_band)?;
for source in &polygon.sources {
let &EdgeSource::Neighbour(neighbour) = source else { continue };
if neighbour_planes.contains_key(&neighbour) || tangent_bands.contains(&neighbour) {
continue;
}
let other = face_by_id(solid, neighbour)?;
if motion.rotation.is_some()
&& !moved_set.contains(&neighbour)
&& plane_of_surface(&other.surface, tolerance.max(1e-9) * 10.0, OP).is_err()
&& band_is_tangent_along_shared_edges(face, other, normal)
{
tangent_bands.insert(neighbour);
continue;
}
let plane = plane_of_surface(&other.surface, tolerance.max(1e-9) * 10.0, OP)
.map_err(|error| {
KernelRefusal::unsupported(
KernelStage::Classify,
"curved_neighbour",
format!(
"{error} — face {id}{} is bounded by face {neighbour}{}, and this road \
needs every bounding carrier as a half-space",
named(face),
named(other)
),
)
})?;
let outward = if other.same_sense {
plane.normal
} else {
plane.normal.scale(-1.0)
};
let home = (outward, outward.dot(plane.origin));
let left = if moved_set.contains(&neighbour) {
motion.plane(home.0, home.1)
} else {
home
};
neighbour_planes.insert(neighbour, (home, left));
}
let hinge = if unturned {
None
} else {
polygon.frame.hinge(normal, offset, moved_normal, moved_offset)
};
let cells = convex_cells(&polygon, hinge, edge_band, scale).map_err(|error| {
KernelRefusal::internal(
KernelStage::Classify,
"cell_split",
format!("{OP}: face {id}{}'s trim could not be cut into convex cells — {error}", named(face)),
)
})?;
let surround = if tangent_bands.is_empty() {
None
} else {
Some(tangent_surround(solid, face, normal, offset, &tangent_bands, &moved_set, tolerance)?)
};
for cell in &cells {
let seat = polygon.frame.lift(cell.centroid());
let delta = if motion.rotation.is_none() {
translation_delta
} else {
(moved_offset - moved_normal.dot(seat)) / facing
};
if delta.abs() <= motion_floor {
if unturned {
continue;
}
return Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"turned_through_trim",
format!(
"{OP}: the motion turns face {id}{} about a line through its own trim, and a \
cell of the trim still sits on that line after the hinge split, so which way \
it moves is not decided by the geometry; refusing rather than choosing",
named(face)
),
));
}
let adds = delta > 0.0;
let (inner, outer) = if adds {
((moved_normal, moved_offset), (normal, offset))
} else {
((normal, offset), (moved_normal, moved_offset))
};
let wedge = |inner: (Vec3, f64), outer: (Vec3, f64)| {
[
HalfSpace { normal: inner.0, offset: inner.1 },
HalfSpace { normal: outer.0.scale(-1.0), offset: -outer.1 },
]
};
let touches_band = cell.sources.iter().any(|source| {
matches!(source, EdgeSource::Neighbour(neighbour) if tangent_bands.contains(neighbour))
});
let (inner, outer) = match (&surround, adds && touches_band) {
(Some(surround), true) => (inner, (outer.0, outer.1 - surround.inner_wall_depth)),
_ => (inner, outer),
};
let mut spaces = clip.clone();
if let (Some(surround), true) = (&surround, touches_band) {
for plane in &surround.planes {
push_unique(
&mut spaces,
HalfSpace { normal: plane.0, offset: plane.1 },
tolerance,
);
}
}
if motion.rotation.is_none() {
spaces.push(HalfSpace {
normal,
offset: offset.max(offset + delta),
});
spaces.push(HalfSpace {
normal: normal.scale(-1.0),
offset: -offset.min(offset + delta),
});
} else {
spaces.extend(wedge(inner, outer));
}
let probe = seat.add(normal.scale(delta * 0.5));
let mut at_home: Vec<HalfSpace> = Vec::new();
let mut sides: Vec<HalfSpace> = Vec::new();
for (edge, source) in cell.sources.iter().enumerate() {
if matches!(source, EdgeSource::Neighbour(neighbour) if tangent_bands.contains(neighbour)) {
let plane = polygon.frame.edge_plane(cell, edge);
let home_sign = if plane.0.dot(seat) - plane.1 > 0.0 { -1.0 } else { 1.0 };
push_unique(
&mut at_home,
HalfSpace { normal: plane.0.scale(home_sign), offset: plane.1 * home_sign },
tolerance,
);
continue;
}
let (home, bound) = match source {
EdgeSource::Neighbour(neighbour) => {
let &(home, left) = neighbour_planes
.get(neighbour)
.expect("every neighbour's plane was read above");
let material_side = home.0.dot(seat) - home.1 < 0.0;
let bound = if material_side == adds { left } else { home };
(home, bound)
}
EdgeSource::Hinge => {
let plane = polygon.frame.edge_plane(cell, edge);
(plane, plane)
}
};
let reach = bound.0.dot(probe) - bound.1;
if reach.abs() <= tolerance {
return Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"probe_on_carrier",
format!(
"{OP}: the probe point for face {id} lies ON the carrier of one of its \
bounds ({reach:.3e} from it), so which side of that carrier the tool \
is on is not decided by the geometry; refusing rather than choosing"
),
));
}
let sign = if reach > 0.0 { -1.0 } else { 1.0 };
let bound = HalfSpace {
normal: bound.0.scale(sign),
offset: bound.1 * sign,
};
let home_sign = if home.0.dot(seat) - home.1 > 0.0 { -1.0 } else { 1.0 };
push_unique(&mut spaces, bound, tolerance);
push_unique(&mut sides, bound, tolerance);
push_unique(
&mut at_home,
HalfSpace {
normal: home.0.scale(home_sign),
offset: home.1 * home_sign,
},
tolerance,
);
}
cell_is_its_bounds(face, cell, &polygon.frame, &at_home, scale)?;
if motion.rotation.is_some() {
let (home_inner, home_outer) = if adds {
((moved_normal, moved_offset), (normal, offset))
} else {
((normal, offset), (moved_normal, moved_offset))
};
one_way_only(id, &clip, &sides, wedge(home_outer, home_inner), tolerance, || named(face))?;
}
let tool = convex_solid(&spaces, probe, tolerance).map_err(|error| {
KernelRefusal::internal(
KernelStage::Fragment,
"tool_polytope",
format!("{OP}: the tool for face {id} could not be built — {error}"),
)
})?;
if adds {
adding.push(tool);
} else {
removing.push(tool);
}
}
}
if removing.is_empty() && adding.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Classify,
"in_plane_motion",
format!(
"{OP}: every selected face slides inside its own carrier (the motion has no \
component along any of their normals), so there is no material for a re-cut to \
move"
),
));
}
let unite = |mut tools: Vec<BrepSolid>| -> Result<Option<BrepSolid>, KernelRefusal> {
if tools.is_empty() {
return Ok(None);
}
let mut tool = tools.remove(0);
for next in tools {
tool = boolean_operation(&tool, &next, BooleanOperation::Union, &BooleanOptions::default())
.map_err(|error| {
error.with_message(|message| {
format!("{OP}: the tools could not be unioned into one — {message}")
})
})?;
}
Ok(Some(tool))
};
let mut cut = solid.clone();
if let Some(tool) = unite(removing)? {
cut = boolean_operation(&cut, &tool, BooleanOperation::Subtract, &BooleanOptions::default())
.map_err(|error| {
error.with_message(|message| format!("{OP}: the re-cut's subtract refused — {message}"))
})?;
}
if let Some(tool) = unite(adding)? {
cut = boolean_operation(&cut, &tool, BooleanOperation::Union, &BooleanOptions::default())
.map_err(|error| {
error.with_message(|message| format!("{OP}: the re-cut's union refused — {message}"))
})?;
}
Ok(restamp(cut, solid, &moved_set, &motion, tolerance))
}
pub fn reblend_lost_fillets(original: &BrepSolid, moved: &[u64], mut body: BrepSolid) -> (BrepSolid, Vec<String>) {
let tolerance = (solid_model_scale(original) * 1e-6).max(1e-9);
let moved_set: HashSet<u64> = moved.iter().copied().collect();
let faces: HashMap<u64, &FaceRecord> =
original.shells.iter().flat_map(|shell| &shell.faces).map(|face| (face.id, face)).collect();
let mut owners: HashMap<u64, Vec<u64>> = HashMap::default();
for face in faces.values() {
for coedge in face.loops.iter().flat_map(|wire| &wire.coedges) {
owners.entry(coedge.edge_id).or_default().push(face.id);
}
}
let across = |face: &FaceRecord| -> Vec<u64> {
let mut found: Vec<u64> = face
.loops
.iter()
.flat_map(|wire| &wire.coedges)
.flat_map(|coedge| owners.get(&coedge.edge_id).into_iter().flatten().copied())
.filter(|id| *id != face.id)
.collect();
found.sort_unstable();
found.dedup();
found
};
let mut notes = Vec::new();
let mut ordered = moved.to_vec();
ordered.sort_unstable();
for id in ordered {
let Some(&face) = faces.get(&id) else { continue };
let Ok(plane) = plane_of_surface(&face.surface, tolerance * 10.0, OP) else { continue };
for band_id in across(face) {
let band = faces[&band_id];
let Some(band_name) = band.name.clone() else { continue };
if moved_set.contains(&band_id)
|| plane_of_surface(&band.surface, tolerance * 10.0, OP).is_ok()
|| !band_is_tangent_along_shared_edges(face, band, plane.normal)
|| body.shells.iter().flat_map(|shell| &shell.faces).any(|f| f.name.as_deref() == Some(&band_name))
{
continue;
}
let radius = super::face_move::ruled_revolution_carrier(&band.surface)
.filter(|(_, rho0, rho1, _)| (rho0 - rho1).abs() <= tolerance)
.map(|(_, rho0, _, _)| rho0);
let Some(rho0) = radius else {
notes.push(format!(
"transformFace: fillet `{band_name}` was lost: the moved face no longer reaches \
it, so it was filled in, and only a constant-radius fillet can be re-made"
));
continue;
};
let remade = across(band)
.into_iter()
.filter(|wall| *wall != id)
.find_map(|wall| {
let wall = faces[&wall];
let wall_plane = plane_of_surface(&wall.surface, tolerance * 10.0, OP).ok()?;
band_is_tangent_along_shared_edges(wall, band, wall_plane.normal).then_some(wall)
})
.ok_or_else(|| "the wall beyond it could not be read".to_string())
.and_then(|wall| sharp_edge_between(&body, face, wall))
.and_then(|edge| {
let rebuilt = crate::fillet_edge(&body, edge, rho0, Some(&band_name)).map_err(|e| e.to_string())?;
let issues = rebuilt.validate();
if !issues.is_empty() {
return Err(format!("the new fillet is invalid: {issues:?}"));
}
crate::accept_sound(rebuilt, "transformFace").map_err(|e| e.to_string())
});
match remade {
Ok(rebuilt) => {
body = rebuilt;
for beside in across(band) {
let beside = faces[&beside];
let Some(beside_name) = beside.name.as_deref() else { continue };
if !moved_set.contains(&beside.id)
&& plane_of_surface(&beside.surface, tolerance * 10.0, OP).is_err()
&& !body.shells.iter().flat_map(|shell| &shell.faces).any(|f| f.name.as_deref() == Some(beside_name))
{
notes.push(format!(
"transformFace: blend `{beside_name}`, which ran round fillet \
`{band_name}`, was not re-made: the fillet was rebuilt at its radius \
and the faces either side of that blend now meet over it"
));
}
}
}
Err(reason) => notes.push(format!(
"transformFace: fillet `{band_name}` (radius {rho0}) was lost: the moved face no \
longer reaches it, so it was filled in, and it could not be re-made ({reason})"
)),
}
}
}
(body, notes)
}
fn sharp_edge_between(body: &BrepSolid, first: &FaceRecord, second: &FaceRecord) -> Result<u64, String> {
let edges_of = |face: &FaceRecord| -> Result<HashSet<u64>, String> {
let name = face.name.as_deref().ok_or_else(|| format!("face {} has no name to find it by", face.id))?;
Ok(body
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter(|f| f.name.as_deref() == Some(name))
.flat_map(|f| f.loops.iter().flat_map(|wire| &wire.coedges).map(|coedge| coedge.edge_id))
.collect())
};
let (a, b) = (edges_of(first)?, edges_of(second)?);
let mut shared = a.intersection(&b).copied();
match (shared.next(), shared.next()) {
(Some(edge), None) => Ok(edge),
_ => Err("no single sharp edge joins the turned face to the wall beyond it".to_string()),
}
}
struct TangentSurround {
planes: Vec<(Vec3, f64)>,
inner_wall_depth: f64,
}
fn band_is_tangent_along_shared_edges(
face: &FaceRecord,
band: &FaceRecord,
normal: Vec3,
) -> bool {
let own: HashSet<u64> =
face.loops.iter().flat_map(|wire| &wire.coedges).map(|coedge| coedge.edge_id).collect();
let mut shared = 0usize;
for coedge in band.loops.iter().flat_map(|wire| &wire.coedges) {
if !own.contains(&coedge.edge_id) {
continue;
}
let Ok([q0, q1]) = coedge.pcurve.domain() else {
return false;
};
let Ok(uv) = coedge.pcurve.evaluate(0.5 * (q0 + q1)) else {
return false;
};
let Ok(band_normal) = band.surface.normal(uv.x, uv.y) else {
return false;
};
if band_normal.cross(normal).length() > 1e-4 {
return false;
}
shared += 1;
}
shared > 0
}
fn tangent_surround(
solid: &BrepSolid,
face: &FaceRecord,
normal: Vec3,
offset: f64,
bands: &HashSet<u64>,
moved: &HashSet<u64>,
tolerance: f64,
) -> Result<TangentSurround, KernelRefusal> {
let mut owners: HashMap<u64, Vec<u64>> = HashMap::default();
for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
for coedge in other.loops.iter().flat_map(|wire| &wire.coedges) {
owners.entry(coedge.edge_id).or_default().push(other.id);
}
}
let edges: HashMap<u64, &EdgeRecord> = solid.edges.iter().map(|edge| (edge.id, edge)).collect();
let points: HashMap<u64, Vec3> =
solid.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
let plane_band = tolerance.max(1e-9) * 10.0;
let mut curved: HashSet<u64> = bands.clone();
let mut planar: HashSet<u64> = HashSet::default();
let mut stack: Vec<u64> = bands.iter().copied().collect();
while let Some(id) = stack.pop() {
let current = face_by_id(solid, id)?;
for coedge in current.loops.iter().flat_map(|wire| &wire.coedges) {
for &next in owners.get(&coedge.edge_id).into_iter().flatten() {
if next == id || next == face.id || curved.contains(&next) || planar.contains(&next) {
continue;
}
if moved.contains(&next) {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"curved_neighbour",
format!(
"{OP}: face {next} is also selected and borders the fillet beside face \
{}, so what bounds the tool past that fillet is itself moving; refusing",
face.id
),
));
}
let other = face_by_id(solid, next)?;
if plane_of_surface(&other.surface, plane_band, OP).is_ok() {
planar.insert(next);
} else {
curved.insert(next);
stack.push(next);
}
}
}
}
let vertices_of = |id: u64| -> Result<Vec<Vec3>, KernelRefusal> {
let mut found = Vec::new();
for coedge in face_by_id(solid, id)?.loops.iter().flat_map(|wire| &wire.coedges) {
if let Some(edge) = edges.get(&coedge.edge_id) {
found.extend(points.get(&edge.start_vertex_id).copied());
found.extend(points.get(&edge.end_vertex_id).copied());
}
}
Ok(found)
};
let mut region: Vec<Vec3> = Vec::new();
let mut depth = 0.0f64;
for &id in &curved {
for point in vertices_of(id)? {
depth = depth.max(offset - normal.dot(point));
region.push(point);
}
}
let mut ordered: Vec<u64> = planar.iter().copied().collect();
ordered.sort_unstable();
for &id in &ordered {
region.extend(vertices_of(id)?);
}
let mut planes = Vec::new();
for &id in &ordered {
let other = face_by_id(solid, id)?;
let plane = plane_of_surface(&other.surface, plane_band, OP)
.map_err(|error| KernelRefusal::unsupported(KernelStage::Classify, "curved_neighbour", error.to_string()))?;
let outward = if other.same_sense { plane.normal } else { plane.normal.scale(-1.0) };
let outward_offset = outward.dot(plane.origin);
if let Some(point) = region.iter().find(|point| outward.dot(**point) - outward_offset > plane_band) {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"curved_neighbour",
format!(
"{OP}: the part is not convex around the fillet beside face {} (face {id}{} has \
material on both sides, at {point:?}), so the wedge a turn adds there has no \
bound that keeps it out of the part's other features; refusing",
face.id,
named(other)
),
));
}
planes.push((outward, outward_offset));
}
Ok(TangentSurround { planes, inner_wall_depth: depth * 1.5 + tolerance })
}
fn push_unique(spaces: &mut Vec<HalfSpace>, space: HalfSpace, tolerance: f64) {
let band = tolerance.max(1e-9) * 10.0;
if spaces.iter().any(|other| {
other.normal.sub(space.normal).length() <= 1e-9 && (other.offset - space.offset).abs() <= band
}) {
return;
}
spaces.push(space);
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum EdgeSource {
Neighbour(u64),
Hinge,
}
#[derive(Clone, Copy, Debug)]
struct Frame {
origin: Vec3,
u: Vec3,
v: Vec3,
}
impl Frame {
fn new(origin: Vec3, normal: Vec3) -> Result<Self, String> {
let u = normal.perpendicular()?.normalized()?;
let v = normal.cross(u).normalized()?;
Ok(Self { origin, u, v })
}
fn drop(&self, point: Vec3) -> [f64; 2] {
let delta = point.sub(self.origin);
[delta.dot(self.u), delta.dot(self.v)]
}
fn lift(&self, point: [f64; 2]) -> Vec3 {
self.origin.add(self.u.scale(point[0])).add(self.v.scale(point[1]))
}
fn hinge(&self, normal: Vec3, offset: f64, moved: Vec3, moved_offset: f64) -> Option<Line2> {
let direction = normal.cross(moved);
if direction.length() <= 1e-12 {
return None;
}
let direction = direction.normalized().ok()?;
let across = direction.cross(normal).normalized().ok()?;
let rate = moved.dot(across);
if rate.abs() <= 1e-12 {
return None;
}
let _ = offset;
let point = self.origin.add(across.scale((moved_offset - moved.dot(self.origin)) / rate));
let [px, py] = self.drop(point);
let [qx, qy] = self.drop(point.add(direction));
Some(Line2 {
point: [px, py],
direction: [qx - px, qy - py],
})
}
fn edge_plane(&self, cell: &Cell, edge: usize) -> (Vec3, f64) {
let a = cell.points[edge];
let b = cell.points[(edge + 1) % cell.points.len()];
let d = [b[0] - a[0], b[1] - a[1]];
let length = (d[0] * d[0] + d[1] * d[1]).sqrt().max(1e-300);
let out = [d[1] / length, -d[0] / length];
let normal = self.u.scale(out[0]).add(self.v.scale(out[1]));
let normal = normal.normalized().unwrap_or(normal);
(normal, normal.dot(self.lift(a)))
}
}
#[derive(Clone, Copy, Debug)]
struct Line2 {
point: [f64; 2],
direction: [f64; 2],
}
impl Line2 {
fn side(&self, p: [f64; 2]) -> f64 {
let d = self.direction;
let length = (d[0] * d[0] + d[1] * d[1]).sqrt().max(1e-300);
(d[0] * (p[1] - self.point[1]) - d[1] * (p[0] - self.point[0])) / length
}
}
#[derive(Clone, Debug)]
struct Cell {
points: Vec<[f64; 2]>,
sources: Vec<EdgeSource>,
}
impl Cell {
fn signed_area(&self) -> f64 {
let n = self.points.len();
(0..n)
.map(|i| {
let a = self.points[i];
let b = self.points[(i + 1) % n];
crate::polygon::area_term(a, b)
})
.sum::<f64>()
* 0.5
}
fn centroid(&self) -> [f64; 2] {
let n = self.points.len();
let area = self.signed_area();
if area.abs() <= 1e-300 {
let mean = self.points.iter().fold([0.0, 0.0], |s, p| [s[0] + p[0], s[1] + p[1]]);
return [mean[0] / n as f64, mean[1] / n as f64];
}
let mut cx = 0.0;
let mut cy = 0.0;
for i in 0..n {
let a = self.points[i];
let b = self.points[(i + 1) % n];
let w = crate::polygon::area_term(a, b);
cx += (a[0] + b[0]) * w;
cy += (a[1] + b[1]) * w;
}
[cx / (6.0 * area), cy / (6.0 * area)]
}
fn reflex_vertex(&self, band: f64) -> Option<usize> {
let n = self.points.len();
(0..n).find(|&i| {
let p = self.points[(i + n - 1) % n];
let q = self.points[i];
let r = self.points[(i + 1) % n];
let a = [q[0] - p[0], q[1] - p[1]];
let b = [r[0] - q[0], r[1] - q[1]];
let la = (a[0] * a[0] + a[1] * a[1]).sqrt();
let lb = (b[0] * b[0] + b[1] * b[1]).sqrt();
if la <= band || lb <= band {
return false;
}
(a[0] * b[1] - a[1] * b[0]) / la.max(lb) < -band
})
}
}
struct TrimPolygon {
frame: Frame,
points: Vec<[f64; 2]>,
sources: Vec<EdgeSource>,
}
fn trim_polygon(
solid: &BrepSolid,
face: &FaceRecord,
normal: Vec3,
band: f64,
) -> Result<TrimPolygon, KernelRefusal> {
let id = face.id;
if face.loops.len() != 1 {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"island_trim",
format!(
"{OP}: face {id}{} has {} loops, so its trim has an island whose walls bound no cell \
of the outer trim, and this road cuts one convex cell at a time; refusing rather \
than filling the island",
named(face),
face.loops.len()
),
));
}
let edges: HashMap<u64, &EdgeRecord> = solid.edges.iter().map(|edge| (edge.id, edge)).collect();
let vertices: HashMap<u64, Vec3> =
solid.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
let mut across: HashMap<u64, u64> = HashMap::default();
for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
if other.id == id {
continue;
}
for coedge in other.loops.iter().flat_map(|wire| &wire.coedges) {
across.entry(coedge.edge_id).or_insert(other.id);
}
}
let mut points3: Vec<Vec3> = Vec::new();
let mut sources: Vec<EdgeSource> = Vec::new();
for coedge in &face.loops[0].coedges {
let edge = edges.get(&coedge.edge_id).ok_or_else(|| {
KernelRefusal::input(
KernelStage::Collect,
"missing_edge",
format!("{OP}: face {id} uses edge {} which the solid does not hold", coedge.edge_id),
)
})?;
if edge.degenerate {
continue;
}
let (start_id, end_id) = if coedge.forward {
(edge.start_vertex_id, edge.end_vertex_id)
} else {
(edge.end_vertex_id, edge.start_vertex_id)
};
let start = *vertices.get(&start_id).ok_or_else(|| {
KernelRefusal::input(
KernelStage::Collect,
"missing_vertex",
format!("{OP}: edge {} starts at vertex {start_id}, which the solid does not hold", edge.id),
)
})?;
let end = *vertices.get(&end_id).ok_or_else(|| {
KernelRefusal::input(
KernelStage::Collect,
"missing_vertex",
format!("{OP}: edge {} ends at vertex {end_id}, which the solid does not hold", edge.id),
)
})?;
let middle = edge
.curve
.evaluate(0.5 * (edge.t0 + edge.t1))
.or_refuse(KernelStage::Classify, "evaluate")?;
let chord_middle = start.add(end).scale(0.5);
if middle.sub(chord_middle).length() > band.max(1e-9) * 100.0 {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"curved_edge",
format!(
"{OP}: edge {} of face {id}{} is not straight (its midpoint is {:.3e} off its \
chord), so the trim is not a polygon this road can cut into cells",
edge.id,
named(face),
middle.sub(chord_middle).length()
),
));
}
let neighbour = *across.get(&edge.id).ok_or_else(|| {
KernelRefusal::unsupported(
KernelStage::Classify,
"open_edge",
format!(
"{OP}: edge {} of face {id}{} is held by no other face, so nothing bounds the \
tool there",
edge.id,
named(face)
),
)
})?;
points3.push(start);
sources.push(EdgeSource::Neighbour(neighbour));
}
if points3.len() < 3 {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"thin_trim",
format!("{OP}: face {id}{} has fewer than three corners", named(face)),
));
}
let frame = Frame::new(points3[0], normal).map_err(|error| {
KernelRefusal::internal(KernelStage::Classify, "frame", format!("{OP}: {error}"))
})?;
let mut points: Vec<[f64; 2]> = points3.iter().map(|point| frame.drop(*point)).collect();
let probe = Cell { points: points.clone(), sources: sources.clone() };
if probe.signed_area() < 0.0 {
points.reverse();
sources.reverse();
sources.rotate_left(1);
let _ = &mut points;
}
Ok(TrimPolygon { frame, points, sources })
}
fn convex_cells(
polygon: &TrimPolygon,
hinge: Option<Line2>,
band: f64,
scale: f64,
) -> Result<Vec<Cell>, String> {
let area_floor = (band * scale).max(1e-18);
let mut pending = vec![Cell {
points: polygon.points.clone(),
sources: polygon.sources.clone(),
}];
let mut convex: Vec<Cell> = Vec::new();
let mut rounds = 0usize;
while let Some(cell) = pending.pop() {
rounds += 1;
if rounds > 256 {
return Err("the reflex cut did not converge in 256 rounds".into());
}
let cell = dedupe(cell, band);
if cell.points.len() < 3 || cell.signed_area().abs() <= area_floor {
continue;
}
match cell.reflex_vertex(band) {
None => convex.push(cell),
Some(vertex) => {
let (first, second) = split_at_reflex(&cell, vertex, band)?;
pending.push(first);
pending.push(second);
}
}
}
let Some(hinge) = hinge else { return Ok(convex) };
let mut cells: Vec<Cell> = Vec::new();
for cell in convex {
let sides: Vec<f64> = cell.points.iter().map(|point| hinge.side(*point)).collect();
let high = sides.iter().copied().fold(f64::MIN, f64::max);
let low = sides.iter().copied().fold(f64::MAX, f64::min);
if high > band && low < -band {
for half in split_by_line(&cell, &sides, band) {
let half = dedupe(half, band);
if half.points.len() >= 3 && half.signed_area().abs() > area_floor {
cells.push(half);
}
}
} else {
cells.push(cell);
}
}
Ok(cells)
}
fn dedupe(cell: Cell, band: f64) -> Cell {
let mut points: Vec<[f64; 2]> = Vec::new();
let mut sources: Vec<EdgeSource> = Vec::new();
for (point, source) in cell.points.iter().zip(&cell.sources) {
if let Some(last) = points.last() {
if (last[0] - point[0]).hypot(last[1] - point[1]) <= band {
*sources.last_mut().expect("as many sources as points") = *source;
continue;
}
}
points.push(*point);
sources.push(*source);
}
while points.len() >= 2 {
let first = points[0];
let last = points[points.len() - 1];
if (last[0] - first[0]).hypot(last[1] - first[1]) <= band {
points.pop();
let source = sources.pop().expect("as many sources as points");
let _ = source;
} else {
break;
}
}
Cell { points, sources }
}
fn split_at_reflex(cell: &Cell, vertex: usize, band: f64) -> Result<(Cell, Cell), String> {
let n = cell.points.len();
let points: Vec<[f64; 2]> = (0..n).map(|i| cell.points[(vertex + i) % n]).collect();
let sources: Vec<EdgeSource> = (0..n).map(|i| cell.sources[(vertex + i) % n]).collect();
let p0 = points[0];
let prev = points[n - 1];
let d = [p0[0] - prev[0], p0[1] - prev[1]];
let length = (d[0] * d[0] + d[1] * d[1]).sqrt();
if length <= band {
return Err("a reflex corner with a zero-length arriving edge".into());
}
let d = [d[0] / length, d[1] / length];
let mut best: Option<(usize, f64, f64)> = None;
for j in 1..(n - 1) {
let a = points[j];
let b = points[(j + 1) % n];
let e = [b[0] - a[0], b[1] - a[1]];
let denominator = d[0] * e[1] - d[1] * e[0];
if denominator.abs() <= 1e-15 {
continue;
}
let w = [a[0] - p0[0], a[1] - p0[1]];
let t = (w[0] * e[1] - w[1] * e[0]) / denominator;
let s = (w[0] * d[1] - w[1] * d[0]) / denominator;
let e_length = (e[0] * e[0] + e[1] * e[1]).sqrt().max(1e-300);
let s_band = band / e_length;
if t <= band || s < -s_band || s > 1.0 + s_band {
continue;
}
if best.map_or(true, |(_, best_t, _)| t < best_t) {
best = Some((j, t, s.clamp(0.0, 1.0)));
}
}
let Some((j, t, s)) = best else {
return Err(format!("the extension of the edge arriving at corner {vertex} meets no boundary edge"));
};
let hit = [p0[0] + d[0] * t, p0[1] + d[1] * t];
let a = points[j];
let b = points[(j + 1) % n];
let e_length = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt().max(1e-300);
let at_a = s * e_length <= band;
let at_b = (1.0 - s) * e_length <= band;
let arriving = sources[n - 1];
let mut first_points: Vec<[f64; 2]> = points[0..=j].to_vec();
let mut first_sources: Vec<EdgeSource> = sources[0..j].to_vec();
if at_b {
first_points.push(b);
first_sources.push(sources[j]);
} else if !at_a {
first_points.push(hit);
first_sources.push(sources[j]);
}
first_sources.push(arriving);
let mut second_points: Vec<[f64; 2]> = Vec::new();
let mut second_sources: Vec<EdgeSource> = Vec::new();
if at_a {
second_points.push(a);
second_sources.push(sources[j]);
} else if !at_b {
second_points.push(hit);
second_sources.push(sources[j]);
}
for k in (j + 1)..n {
second_points.push(points[k]);
second_sources.push(sources[k]);
}
*second_sources.last_mut().expect("the second piece has an edge") = arriving;
Ok((
Cell { points: first_points, sources: first_sources },
Cell { points: second_points, sources: second_sources },
))
}
fn split_by_line(cell: &Cell, sides: &[f64], band: f64) -> [Cell; 2] {
let n = cell.points.len();
let mut positive = Cell { points: Vec::new(), sources: Vec::new() };
let mut negative = Cell { points: Vec::new(), sources: Vec::new() };
for j in 0..n {
let p = cell.points[j];
let q = cell.points[(j + 1) % n];
let (fp, fq) = (sides[j], sides[(j + 1) % n]);
let source = cell.sources[j];
let p_on = fp.abs() <= band;
let q_on = fq.abs() <= band;
if fp > band || p_on {
positive.points.push(p);
positive.sources.push(if !p_on || fq > band || q_on { source } else { EdgeSource::Hinge });
}
if fp < -band || p_on {
negative.points.push(p);
negative.sources.push(if !p_on || fq < -band || q_on { source } else { EdgeSource::Hinge });
}
if (fp > band && fq < -band) || (fp < -band && fq > band) {
let t = fp / (fp - fq);
let x = [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
if fp > band {
positive.points.push(x);
positive.sources.push(EdgeSource::Hinge);
negative.points.push(x);
negative.sources.push(source);
} else {
negative.points.push(x);
negative.sources.push(EdgeSource::Hinge);
positive.points.push(x);
positive.sources.push(source);
}
}
}
[positive, negative]
}
fn one_way_only(
id: u64,
clip: &[HalfSpace],
sides: &[HalfSpace],
other: [HalfSpace; 2],
tolerance: f64,
name: impl Fn() -> String,
) -> Result<(), KernelRefusal> {
let band = tolerance.max(1e-9) * 10.0;
let spaces: Vec<HalfSpace> = clip.iter().chain(sides).chain(&other).copied().collect();
let mut depth = 0.0f64;
for a in 0..spaces.len() {
for b in (a + 1)..spaces.len() {
for c in (b + 1)..spaces.len() {
let Some(point) = three_plane_point(&spaces[a], &spaces[b], &spaces[c]) else {
continue;
};
if spaces
.iter()
.any(|space| space.normal.dot(point) - space.offset > band)
{
continue;
}
let inside = other
.iter()
.map(|space| space.offset - space.normal.dot(point))
.fold(0.0f64, f64::max);
depth = depth.max(inside);
}
}
}
if depth > band * 1e3 {
return Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"one_way_only",
format!(
"{OP}: the motion turns face {id}{} so that the line its old and new carriers meet \
in crosses its own bounds — part of the face moves into the body and part out of it \
(the other wedge reaches {depth:.3e} past them), so one tool would add material \
where the other takes it away; refusing rather than choosing",
name()
),
));
}
Ok(())
}
fn cell_is_its_bounds(
face: &FaceRecord,
cell: &Cell,
frame: &Frame,
at_home: &[HalfSpace],
scale: f64,
) -> Result<(), KernelRefusal> {
let band = (scale * 1e-4).max(1e-6);
let n = cell.points.len();
let mut worst = 0.0f64;
for i in 0..n {
let a = cell.points[i];
let b = cell.points[(i + 1) % n];
for point in [a, [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]] {
let point = frame.lift(point);
for space in at_home {
worst = worst.max(space.normal.dot(point) - space.offset);
}
}
}
if worst > band {
return Err(KernelRefusal::unsupported(
KernelStage::Classify,
"reflex_trim",
format!(
"{OP}: face {}{} has a boundary point {worst:.3e} outside one of its own \
bounding carriers, so its trim is not the region its bounding carriers \
enclose — their intersection is the trim's CONVEX CORE, and the tool this \
road would build is that core rather than all the material the motion \
moves. Refusing rather than cutting some of it",
face.id,
named(face)
),
));
}
Ok(())
}
fn face_by_id(solid: &BrepSolid, id: u64) -> Result<&FaceRecord, KernelRefusal> {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| face.id == id)
.ok_or_else(|| {
KernelRefusal::input(
KernelStage::Collect,
"face_id",
format!("{OP}: no face with id {id} on this solid"),
)
})
}
fn named(face: &FaceRecord) -> String {
face.name
.as_ref()
.map(|name| format!(" '{name}'"))
.unwrap_or_default()
}
fn solid_bounds(solid: &BrepSolid) -> Result<(Vec3, Vec3), KernelRefusal> {
if solid.vertices.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"empty_solid",
format!("{OP}: the solid has no vertices to bound"),
));
}
let mut low = Vec3::new(f64::MAX, f64::MAX, f64::MAX);
let mut high = Vec3::new(f64::MIN, f64::MIN, f64::MIN);
for vertex in &solid.vertices {
low = Vec3::new(
low.x.min(vertex.point.x),
low.y.min(vertex.point.y),
low.z.min(vertex.point.z),
);
high = Vec3::new(
high.x.max(vertex.point.x),
high.y.max(vertex.point.y),
high.z.max(vertex.point.z),
);
}
Ok((low, high))
}
fn box_half_spaces((low, high): (Vec3, Vec3), margin: f64) -> Vec<HalfSpace> {
let low = Vec3::new(low.x - margin, low.y - margin, low.z - margin);
let high = Vec3::new(high.x + margin, high.y + margin, high.z + margin);
vec![
HalfSpace { normal: Vec3::new(1.0, 0.0, 0.0), offset: high.x },
HalfSpace { normal: Vec3::new(-1.0, 0.0, 0.0), offset: -low.x },
HalfSpace { normal: Vec3::new(0.0, 1.0, 0.0), offset: high.y },
HalfSpace { normal: Vec3::new(0.0, -1.0, 0.0), offset: -low.y },
HalfSpace { normal: Vec3::new(0.0, 0.0, 1.0), offset: high.z },
HalfSpace { normal: Vec3::new(0.0, 0.0, -1.0), offset: -low.z },
]
}
fn convex_solid(
spaces: &[HalfSpace],
inside: Vec3,
tolerance: f64,
) -> Result<BrepSolid, String> {
let band = tolerance.max(1e-9) * 10.0;
for space in spaces {
if space.normal.dot(inside) - space.offset > -band {
return Err(format!(
"the seat point is not strictly inside the half-space set (it is {:.3e} past one \
of them), so the slab and its bounds enclose nothing",
space.normal.dot(inside) - space.offset
));
}
}
let mut corners: Vec<Vec3> = Vec::new();
for a in 0..spaces.len() {
for b in (a + 1)..spaces.len() {
for c in (b + 1)..spaces.len() {
let Some(point) = three_plane_point(&spaces[a], &spaces[b], &spaces[c]) else {
continue;
};
if spaces
.iter()
.any(|space| space.normal.dot(point) - space.offset > band)
{
continue;
}
if corners
.iter()
.any(|other| other.sub(point).length() <= band)
{
continue;
}
corners.push(point);
}
}
}
if corners.len() < 4 {
return Err(format!(
"the half-space set has {} corner(s), which is not a bounded volume",
corners.len()
));
}
let vertices: Vec<VertexRecord> = corners
.iter()
.enumerate()
.map(|(index, point)| VertexRecord { id: index as u64 + 1, point: *point })
.collect();
let mut edges: Vec<EdgeRecord> = Vec::new();
let mut edge_index: HashMap<(usize, usize), u64> = HashMap::default();
let mut faces: Vec<FaceRecord> = Vec::new();
let mut next_id = 1000u64;
for space in spaces {
let mut on: Vec<usize> = (0..corners.len())
.filter(|&index| (space.normal.dot(corners[index]) - space.offset).abs() <= band)
.collect();
if on.len() < 3 {
continue;
}
let centroid = on
.iter()
.fold(Vec3::new(0.0, 0.0, 0.0), |sum, &index| sum.add(corners[index]))
.scale(1.0 / on.len() as f64);
let u_dir = corners[on[0]].sub(centroid).normalized()?;
let v_dir = space.normal.cross(u_dir).normalized()?;
on.sort_by(|&a, &b| {
let angle = |index: usize| {
let delta = corners[index].sub(centroid);
delta.dot(v_dir).atan2(delta.dot(u_dir))
};
angle(a).total_cmp(&angle(b))
});
faces.push(polygon_face(
&vertices,
&on,
space.normal,
&mut edges,
&mut edge_index,
&mut next_id,
)?);
}
if faces.len() < 4 {
return Err(format!(
"the half-space set produced {} face(s), which does not close a volume",
faces.len()
));
}
let shell_id = next_id + 1;
let solid_id = next_id + 2;
let solid = BrepSolid {
mass_properties_cache: Default::default(),
cells: Default::default(),
id: solid_id,
vertices,
edges,
shells: vec![ShellRecord { id: shell_id, faces }],
genus: 0,
kind: crate::BodyKind::Solid,
};
let issues = solid.validate();
if !issues.is_empty() {
return Err(format!("the tool polytope did not validate: {issues:?}"));
}
Ok(solid)
}
fn three_plane_point(a: &HalfSpace, b: &HalfSpace, c: &HalfSpace) -> Option<Vec3> {
let cross = b.normal.cross(c.normal);
let determinant = a.normal.dot(cross);
if determinant.abs() <= 1e-9 {
return None;
}
let point = cross
.scale(a.offset)
.add(c.normal.cross(a.normal).scale(b.offset))
.add(a.normal.cross(b.normal).scale(c.offset))
.scale(1.0 / determinant);
Some(point)
}
fn polygon_face(
vertices: &[VertexRecord],
corners: &[usize],
normal: Vec3,
edges: &mut Vec<EdgeRecord>,
edge_index: &mut HashMap<(usize, usize), u64>,
next_id: &mut u64,
) -> Result<FaceRecord, String> {
let origin_point = vertices[corners[0]].point;
let u_dir = vertices[corners[1]]
.point
.sub(origin_point)
.normalized()?;
let v_dir = normal.cross(u_dir).normalized()?;
let mut low_u = f64::MAX;
let mut low_v = f64::MAX;
let mut high_u = f64::MIN;
let mut high_v = f64::MIN;
let projected: Vec<(f64, f64)> = corners
.iter()
.map(|&index| {
let delta = vertices[index].point.sub(origin_point);
let (u, v) = (delta.dot(u_dir), delta.dot(v_dir));
low_u = low_u.min(u);
low_v = low_v.min(v);
high_u = high_u.max(u);
high_v = high_v.max(v);
(u, v)
})
.collect();
let plane_origin = origin_point.add(u_dir.scale(low_u)).add(v_dir.scale(low_v));
let surface = make_plane(
plane_origin,
u_dir,
v_dir,
high_u - low_u,
high_v - low_v,
)?;
let mut coedges: Vec<CoedgeRecord> = Vec::with_capacity(corners.len());
for index in 0..corners.len() {
let start = corners[index];
let end = corners[(index + 1) % corners.len()];
let key = if start < end { (start, end) } else { (end, start) };
let edge_id = match edge_index.get(&key) {
Some(id) => *id,
None => {
let id = *next_id;
*next_id += 1;
edges.push(EdgeRecord {
id,
curve: make_line(vertices[key.0].point, vertices[key.1].point)?,
t0: 0.0,
t1: 1.0,
start_vertex_id: vertices[key.0].id,
end_vertex_id: vertices[key.1].id,
degenerate: false,
name: None,
});
edge_index.insert(key, id);
id
}
};
let start_uv = projected[index];
let end_uv = projected[(index + 1) % corners.len()];
coedges.push(CoedgeRecord {
id: *next_id,
edge_id,
forward: vertices[start].id
== edges
.iter()
.find(|edge| edge.id == edge_id)
.expect("the edge was just minted")
.start_vertex_id,
pcurve: parameter_line(
start_uv.0 - low_u,
start_uv.1 - low_v,
end_uv.0 - low_u,
end_uv.1 - low_v,
)?,
});
*next_id += 1;
}
let loop_id = *next_id;
*next_id += 1;
let face_id = *next_id;
*next_id += 1;
Ok(FaceRecord {
id: face_id,
surface,
same_sense: true,
loops: vec![LoopRecord { id: loop_id, coedges }],
name: None,
})
}
fn restamp(
mut cut: BrepSolid,
original: &BrepSolid,
moved: &HashSet<u64>,
motion: &Motion,
tolerance: f64,
) -> BrepSolid {
let band = tolerance.max(1e-9) * 1e3;
let mut wanted: Vec<(String, Vec3, f64)> = Vec::new();
for face in original.shells.iter().flat_map(|shell| &shell.faces) {
let Some(name) = face.name.clone() else { continue };
let Ok(plane) = plane_of_surface(&face.surface, band, OP) else {
continue;
};
let normal = if face.same_sense {
plane.normal
} else {
plane.normal.scale(-1.0)
};
let (normal, offset) = if moved.contains(&face.id) {
motion.plane(normal, normal.dot(plane.origin))
} else {
(normal, normal.dot(plane.origin))
};
wanted.push((name, normal, offset));
}
let taken: HashSet<String> = cut
.shells
.iter()
.flat_map(|shell| &shell.faces)
.filter_map(|face| face.name.clone())
.collect();
let mut used: HashMap<String, usize> = HashMap::default();
for name in &taken {
used.insert(name.clone(), 1);
}
for shell in &mut cut.shells {
for face in &mut shell.faces {
if face.name.is_some() {
continue;
}
let Ok(plane) = plane_of_surface(&face.surface, band, OP) else {
continue;
};
let normal = if face.same_sense {
plane.normal
} else {
plane.normal.scale(-1.0)
};
let offset = normal.dot(plane.origin);
let Some((name, _, _)) = wanted.iter().find(|(_, other, other_offset)| {
other.sub(normal).length() <= 1e-9 && (other_offset - offset).abs() <= band
}) else {
continue;
};
let count = used.entry(name.clone()).or_insert(0);
*count += 1;
face.name = Some(if *count == 1 {
name.clone()
} else {
format!("{name}_{}", *count - 1)
});
}
}
cut
}