use super::*;
use crate::offset_retrim::plane_of_surface;
use crate::topology::ShellRecord;
use crate::{
boolean_operation, make_plane, parameter_point_in_face, BooleanOperation, BooleanOptions,
PolygonClass, Vec2,
};
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,
}
const PROBE_STATIONS: usize = 16;
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)));
}
let motion_floor = (scale * 1e-9).max(1e-12);
let mut contributors: Vec<(u64, Vec3, f64, f64, Option<Vec3>)> = Vec::new();
for &(id, normal, offset) in &carriers {
let mut seat = None;
let delta = if motion.rotation.is_none() {
translation.dot(normal)
} else {
let seat = *seat.insert(interior_point(solid, id, tolerance)?);
let (moved_normal, moved_offset) = motion.plane(normal, offset);
let facing = moved_normal.dot(normal);
if 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_by_id(solid, id)?)
),
));
}
let reach = (moved_offset - moved_normal.dot(seat)) / facing;
let unturned = moved_normal.sub(normal).length() <= 1e-12;
if reach.abs() <= motion_floor && !unturned {
return Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"turned_through_trim",
format!(
"{OP}: the motion turns face {id}{} about a line through its own trim, so \
part of it moves into the body and part out of it; one tool would add \
material where the other takes it away, and this road refuses rather than \
choosing",
named(face_by_id(solid, id)?)
),
));
}
reach
};
if delta.abs() <= motion_floor {
continue;
}
contributors.push((id, normal, offset, delta, seat));
}
if contributors.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 adds = contributors[0].3 > 0.0;
if contributors.iter().any(|entry| (entry.3 > 0.0) != adds) {
return Err(KernelRefusal::ill_posed(
KernelStage::Classify,
"mixed_directions",
format!(
"{OP}: the selection moves {} face(s) OUT along their own normals and {} face(s) IN, \
so one tool would add material where another takes it away and the order they are \
applied in would decide the answer; refusing rather than picking one",
contributors.iter().filter(|entry| entry.3 > 0.0).count(),
contributors.iter().filter(|entry| entry.3 < 0.0).count(),
),
));
}
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 mut tools: Vec<BrepSolid> = Vec::new();
for &(id, normal, offset, delta, seat) in &contributors {
let seat = match seat {
Some(seat) => seat,
None => interior_point(solid, id, tolerance)?,
};
let (moved_normal, moved_offset) = motion.plane(normal, offset);
let (inner, outer) = if delta < 0.0 {
((normal, offset), (moved_normal, moved_offset))
} else {
((moved_normal, moved_offset), (normal, 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 mut spaces = clip.clone();
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 neighbour in neighbours_of(solid, id) {
if neighbour == id {
continue;
}
let face = face_by_id(solid, neighbour)?;
let plane = plane_of_surface(&face.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_by_id(solid, id).unwrap_or(face)),
named(face)
),
)
},
)?;
let mut normal_g = if face.same_sense {
plane.normal
} else {
plane.normal.scale(-1.0)
};
let mut home_g = normal_g.dot(plane.origin);
let mut offset_g = home_g;
if moved_set.contains(&neighbour) {
(normal_g, offset_g) = motion.plane(normal_g, home_g);
}
let mut home_normal = if face.same_sense {
plane.normal
} else {
plane.normal.scale(-1.0)
};
let reach = normal_g.dot(probe) - offset_g;
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 its neighbour \
{neighbour} ({reach:.3e} from it), so which side of that carrier the tool \
is on is not decided by the geometry; refusing rather than choosing"
),
));
}
if reach > 0.0 {
normal_g = normal_g.scale(-1.0);
offset_g = -offset_g;
home_g = -home_g;
home_normal = home_normal.scale(-1.0);
}
let bound = HalfSpace {
normal: normal_g,
offset: offset_g,
};
spaces.push(bound);
sides.push(bound);
at_home.push(HalfSpace {
normal: home_normal,
offset: home_g,
});
}
contains_its_own_trim(solid, id, &at_home, scale)?;
if motion.rotation.is_some() {
one_way_only(id, &clip, &sides, wedge(outer, inner), tolerance, || {
named(face_by_id(solid, id).expect("the face was read above"))
})?;
}
tools.push(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}"),
)
})?);
}
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}")
})
})?;
}
let operation = if adds {
BooleanOperation::Union
} else {
BooleanOperation::Subtract
};
let cut = boolean_operation(solid, &tool, operation, &BooleanOptions::default()).map_err(
|error| {
error.with_message(|message| {
format!(
"{OP}: the re-cut's {} refused — {message}",
if adds { "union" } else { "subtract" },
)
})
},
)?;
Ok(restamp(cut, solid, &moved_set, &motion, tolerance))
}
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 contains_its_own_trim(
solid: &BrepSolid,
id: u64,
at_home: &[HalfSpace],
scale: f64,
) -> Result<(), KernelRefusal> {
let band = (scale * 1e-4).max(1e-6);
let face = face_by_id(solid, id)?;
let edges: HashMap<u64, EdgeRecord> = solid
.edges
.iter()
.map(|edge| (edge.id, edge.clone()))
.collect();
let mut worst = 0.0f64;
for point in boundary_samples(face, &edges, OP)? {
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 {id}{} 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",
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 neighbours_of(solid: &BrepSolid, id: u64) -> Vec<u64> {
let mut edges: HashSet<u64> = HashSet::default();
for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
if face.id != id {
continue;
}
for use_ in face.loops.iter().flat_map(|wire| &wire.coedges) {
edges.insert(use_.edge_id);
}
}
let mut found: Vec<u64> = Vec::new();
for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
if face.id == id || found.contains(&face.id) {
continue;
}
if face
.loops
.iter()
.flat_map(|wire| &wire.coedges)
.any(|use_| edges.contains(&use_.edge_id))
{
found.push(face.id);
}
}
found
}
fn interior_point(solid: &BrepSolid, id: u64, tolerance: f64) -> Result<Vec3, KernelRefusal> {
let face = face_by_id(solid, id)?;
let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Classify, "domain")?;
let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Classify, "domain")?;
let mut inside: Vec<Vec2> = Vec::new();
for iu in 1..PROBE_STATIONS {
for iv in 1..PROBE_STATIONS {
let point = Vec2 {
x: u0 + (u1 - u0) * iu as f64 / PROBE_STATIONS as f64,
y: v0 + (v1 - v0) * iv as f64 / PROBE_STATIONS as f64,
};
if matches!(
parameter_point_in_face(face, point, tolerance),
Ok(PolygonClass::Inside)
) {
inside.push(point);
}
}
}
if inside.is_empty() {
return Err(KernelRefusal::non_convergence(
KernelStage::Classify,
"seat_lattice",
format!(
"{OP}: no point of a {PROBE_STATIONS}x{PROBE_STATIONS} lattice over face {id}'s \
parameter square lands inside its trim, so this road has no seat to read the tool's \
side from"
),
));
}
let mean = Vec2 {
x: inside.iter().map(|point| point.x).sum::<f64>() / inside.len() as f64,
y: inside.iter().map(|point| point.y).sum::<f64>() / inside.len() as f64,
};
let seat = if matches!(
parameter_point_in_face(face, mean, tolerance),
Ok(PolygonClass::Inside)
) {
mean
} else {
*inside
.iter()
.max_by(|a, b| {
let count = |point: &Vec2| {
inside
.iter()
.filter(|other| {
(other.x - point.x).abs() <= (u1 - u0) / PROBE_STATIONS as f64 * 1.5
&& (other.y - point.y).abs()
<= (v1 - v0) / PROBE_STATIONS as f64 * 1.5
})
.count()
};
count(a).cmp(&count(b))
})
.expect("the list is not empty")
};
face.surface
.evaluate(seat.x, seat.y)
.or_refuse(KernelStage::Classify, "evaluate")
}
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(),
id: solid_id,
vertices,
edges,
shells: vec![ShellRecord { id: shell_id, faces }],
genus: 0,
};
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
}