* Fillets and chamfers meeting at corners on curved faces, on top of #935 What #935 and #926 leave of #851 and the matrix: - Corner build for blends meeting on faces of any surface (curvecorner.rs): the knife-edge tip's round with the arc's or the back edge's, edges picked in any order, a round ending in an earlier round of its radius (as a feature of its own), and chamfers meeting at the arc's corner. Its end curves are fitted by chord length: a uniform parameter through points clustered at the ends doubled back on itself there, and the display planner refused the face. - A straight edge ending at a curved face (the back edge into the knife-edge tip): rolled, or cut by the face in the planar corner build (blend_corner.rs). - A rolling ball turning about a rounded corner of its own radius (a pivot: a piece of sphere or cone). - Seams that are an earlier blend's straight contact (fitted curves) count as straight. - No spline across a joint where the strip's curvature jumps (a line into a small round): each strip ends on the joint's section. - Sections at a chain's vertices on the faces of the edge from it alone. - Every new fillet path passes #949's check for openings. Tests measure volumes through the display plan, as #935's do. Action matrix: 51 more fillets and chamfers pass (sweep, tangent arc chain, sheet-metal hem, bracket holes), none fewer. Robustness seed 33 recorded (its last fillet builds differently; display and measured volumes now agree). Co-Authored-By: bherbruck <brennen.herbruck@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi * Corner blend tests: scale, seed 33's volumes, tighter engine bounds From the review of #854: - seed_33_display_plan_and_measure_agree: seed 33's bodies measure the same through the display plan and the measure (2e-5), which the golden's re-record rests on. - the_arc_rounded_after_the_back_edge_matches_both_together: the order that still leaves 0.05 mm³ at the tip, as an ignored test. - corner_blends_hold_at_any_scale: the cases this branch adds on the #851 part, at 0.01x and 100x, take off what they take at full size times k³ (within 5e-6, checked to 1e-4). - The engine tests on the part as drawn measure through the display plan; bounds are about twice what each reads (2e-4 to 3e-3), and the front corner's sets are checked against the kernel's CSG grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi --------- Co-authored-by: bherbruck <brennen.herbruck@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
723 lines
34 KiB
Rust
723 lines
34 KiB
Rust
//! Blending a whole boundary loop of a planar face whose neighbours are walls standing
|
||
//! perpendicular to it (planes, and cylinders with axes along the face normal): a pocket floor,
|
||
//! the top outline of an extruded plate. The loop must be smooth (lines and arcs meeting
|
||
//! tangentially). The face shrinks by the radius, the walls are trimmed by it, and each loop edge
|
||
//! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along
|
||
//! an arc, all meeting along the profile arcs at the joints. The result is exact.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use solidcraft_geom::Vec3;
|
||
use truck_modeling::{self as mt, builder};
|
||
|
||
use crate::blend::too_large;
|
||
use crate::body::{Solid, from_p3, p3, v3};
|
||
use crate::record::Rebuild;
|
||
use crate::{KernelError, Result};
|
||
|
||
fn unsupported(msg: &str) -> KernelError {
|
||
KernelError::Failed(format!("not supported yet: {msg}"))
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Seg {
|
||
Line {
|
||
a: Vec3,
|
||
b: Vec3,
|
||
},
|
||
/// Arc from `a` to `b` about `c` turning by `angle` about `axis` (right hand, angle > 0).
|
||
Arc {
|
||
a: Vec3,
|
||
c: Vec3,
|
||
axis: Vec3,
|
||
angle: f64,
|
||
mid: Vec3,
|
||
},
|
||
}
|
||
|
||
impl Seg {
|
||
fn start(&self) -> Vec3 {
|
||
match *self {
|
||
Seg::Line { a, .. } | Seg::Arc { a, .. } => a,
|
||
}
|
||
}
|
||
fn end(&self) -> Vec3 {
|
||
match *self {
|
||
Seg::Line { b, .. } => b,
|
||
Seg::Arc { a, c, axis, angle, .. } => turn(a, c, axis, angle),
|
||
}
|
||
}
|
||
/// Unit tangent (direction of travel) at a point of the segment.
|
||
fn tangent(&self, p: Vec3) -> Option<Vec3> {
|
||
match *self {
|
||
Seg::Line { a, b } => (b - a).normalized(),
|
||
Seg::Arc { c, axis, .. } => axis.cross(p - c).normalized(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The exact shape of an oriented loop edge (a line, or an arc found from samples).
|
||
fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
|
||
use mt::{BoundedCurve, ParametricCurve};
|
||
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
|
||
let c = e.oriented_curve();
|
||
let (t0, t1) = c.range_tuple();
|
||
let pts: Vec<Vec3> = (0..=16).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0))).collect();
|
||
let closed = a.dist(b) < tol;
|
||
if !closed && pts.iter().all(|p| p.dist_to_segment(a, b) < tol) {
|
||
return Some(Seg::Line { a, b });
|
||
}
|
||
let m = *pts.get(8)?;
|
||
// A closed edge (a full circle): fit through points a third of the way round.
|
||
let (b_fit, m_fit) = if closed { (*pts.get(5)?, *pts.get(11)?) } else { (b, m) };
|
||
let (u, v) = (b_fit - a, m_fit - a);
|
||
let w = u.cross(v);
|
||
let d = 2.0 * w.len2();
|
||
if d < 1e-300 {
|
||
return None;
|
||
}
|
||
let centre = a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d);
|
||
let r = centre.dist(a);
|
||
if !pts.iter().all(|p| (p.dist(centre) - r).abs() < tol) {
|
||
return None;
|
||
}
|
||
// Turning direction: the travel at a is axis × (a − c).
|
||
let t_a = (*pts.get(1)? - a).normalized()?;
|
||
let mut axis = w.normalized()?;
|
||
if axis.cross(a - centre).dot(t_a) < 0.0 {
|
||
axis = -axis;
|
||
}
|
||
let (ra, rb) = ((a - centre).normalized()?, (b - centre).normalized()?);
|
||
let mut angle = ra.dot(rb).clamp(-1.0, 1.0).acos();
|
||
if ra.cross(rb).dot(axis) < 0.0 {
|
||
angle = std::f64::consts::TAU - angle;
|
||
}
|
||
if closed {
|
||
angle = std::f64::consts::TAU;
|
||
}
|
||
Some(Seg::Arc { a, c: centre, axis, angle, mid: m })
|
||
}
|
||
|
||
/// Whether an edge is a straight segment: a line, or a curve whose points all lie on the chord
|
||
/// (the contact lines of a rolling-ball blend between parallel walls are such curves).
|
||
fn is_straight(e: &mt::Edge, tol: f64) -> bool {
|
||
use mt::{BoundedCurve, ParametricCurve};
|
||
if matches!(e.curve(), mt::Curve::Line(_)) {
|
||
return true;
|
||
}
|
||
let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
|
||
if a.dist(b) < tol {
|
||
return false;
|
||
}
|
||
let c = e.curve();
|
||
let (t0, t1) = c.range_tuple();
|
||
(0..=16).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)).dist_to_segment(a, b) < tol)
|
||
}
|
||
|
||
/// `p` turned about the line (c, axis) by `ang`.
|
||
fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 {
|
||
let k = axis.normalized().unwrap_or(Vec3::Z);
|
||
let v = p - c;
|
||
let (s, co) = ang.sin_cos();
|
||
c + v * co + k.cross(v) * s + k * (k.dot(v) * (1.0 - co))
|
||
}
|
||
|
||
/// Split full circles in two halves (an edge needs two ends).
|
||
fn pieces(segs: &[Seg]) -> Vec<(usize, Seg)> {
|
||
let mut out = Vec::new();
|
||
for (i, s) in segs.iter().enumerate() {
|
||
match *s {
|
||
Seg::Arc { a, c, axis, angle, .. } if angle > std::f64::consts::TAU - 1e-9 => {
|
||
let h = std::f64::consts::PI;
|
||
let a2 = turn(a, c, axis, h);
|
||
out.push((i, Seg::Arc { a, c, axis, angle: h, mid: turn(a, c, axis, h / 2.0) }));
|
||
out.push((i, Seg::Arc { a: a2, c, axis, angle: h, mid: turn(a, c, axis, 1.5 * h) }));
|
||
}
|
||
_ => out.push((i, *s)),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn plane_normal(f: &mt::Face) -> Option<Vec3> {
|
||
match f.oriented_surface() {
|
||
mt::Surface::Plane(p) => {
|
||
let n = p.normal();
|
||
Vec3::new(n.x, n.y, n.z).normalized()
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Outward normal of a face at a point on it.
|
||
fn face_normal(f: &mt::Face, p: Vec3) -> Option<Vec3> {
|
||
use mt::{ParametricSurface3D, SearchNearestParameter, SearchParameter};
|
||
let s = f.oriented_surface();
|
||
let (u, v) = s.search_parameter(p3(p), None, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?;
|
||
let n = s.normal(u, v);
|
||
Vec3::new(n.x, n.y, n.z).normalized()
|
||
}
|
||
|
||
/// Blend the loop of `edges` (by id) if it is a whole smooth boundary loop of a planar face with
|
||
/// perpendicular walls, with what it built from `solid` (each blend face in place of its loop
|
||
/// edge). `None` when the edges are not such a loop.
|
||
pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
|
||
let tol = (size * 1e-6).max(1e-7);
|
||
for (si, shell) in solid.boundaries().iter().enumerate() {
|
||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||
for (fi, f) in faces.iter().enumerate() {
|
||
let Some(n) = plane_normal(f) else { continue };
|
||
for (wi, w) in f.boundaries().iter().enumerate() {
|
||
let ids: Vec<mt::EdgeID> = w.edge_iter().map(|e| e.id()).collect();
|
||
if !edge_ids.iter().all(|e| ids.contains(e)) {
|
||
continue;
|
||
}
|
||
if ids.len() == edge_ids.len() {
|
||
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, None));
|
||
}
|
||
// A run of neighbouring edges of the loop (its ends meet unblended edges).
|
||
let m = ids.len();
|
||
let inside = |i: usize| ids.get(i % m).is_some_and(|x| edge_ids.contains(x));
|
||
let starts: Vec<usize> = (0..m).filter(|i| inside(*i) && !inside(*i + m - 1)).collect();
|
||
if let [s0] = starts[..]
|
||
&& (0..edge_ids.len()).all(|j| inside(s0 + j))
|
||
{
|
||
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, Some((s0, edge_ids.len()))));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build(
|
||
solid: &Solid,
|
||
si: usize,
|
||
faces: &[&mt::Face],
|
||
fi: usize,
|
||
wi: usize,
|
||
n: Vec3,
|
||
size: f64,
|
||
tol: f64,
|
||
r: f64,
|
||
round: bool,
|
||
run: Option<(usize, usize)>,
|
||
) -> Result<(Solid, Rebuild)> {
|
||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
|
||
let all_edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
|
||
// The blended edges in loop order: the whole loop, or a run of it (open).
|
||
let open = run.is_some();
|
||
let edges: Vec<mt::Edge> = match run {
|
||
None => all_edges,
|
||
Some((s0, len)) => (0..len).filter_map(|j| all_edges.get((s0 + j) % all_edges.len().max(1)).cloned()).collect(),
|
||
};
|
||
let edge_segs: Vec<Seg> = edges
|
||
.iter()
|
||
.map(|e| seg_of(e, (size * 1e-5).max(1e-6)))
|
||
.collect::<Option<_>>()
|
||
.ok_or_else(|| unsupported("loop edges must be lines or arcs"))?;
|
||
// Pieces: the loop's edges, full circles in halves; each knows its edge.
|
||
let split = pieces(&edge_segs);
|
||
let owner: Vec<usize> = split.iter().map(|x| x.0).collect();
|
||
let segs: Vec<Seg> = split.iter().map(|x| x.1).collect();
|
||
let k = segs.len();
|
||
// Joints: k round a whole loop; k + 1 along a run (its two ends included).
|
||
let nj = if open { k + 1 } else { k };
|
||
let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k };
|
||
// Joints: smooth, or sharp between two lines (the blends then meet in a mitre).
|
||
let mut mitre = vec![false; nj];
|
||
for i in 0..k {
|
||
if open && i == 0 {
|
||
continue;
|
||
}
|
||
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { continue };
|
||
let p = cur.start();
|
||
let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) };
|
||
if ta.dot(tb) < 1.0 - 1e-6 {
|
||
if !(matches!(prev, Seg::Line { .. }) && matches!(cur, Seg::Line { .. })) || ta.dot(tb) < -1.0 + 1e-3 {
|
||
return Err(unsupported("blending a loop with sharp corners next to arcs"));
|
||
}
|
||
if let Some(x) = mitre.get_mut(i) {
|
||
*x = true;
|
||
}
|
||
}
|
||
}
|
||
// Walls: the other face of each edge, perpendicular to the face; all on one side.
|
||
let mut walls: Vec<usize> = Vec::with_capacity(k);
|
||
let mut side = 0.0;
|
||
for (oi, s) in owner.iter().zip(&segs) {
|
||
let e = edges.get(*oi).ok_or_else(|| KernelError::Failed("edge".into()))?;
|
||
let wf = faces
|
||
.iter()
|
||
.position(|g| !std::ptr::eq(*g, *f) && g.edge_iter().any(|x| x.id() == e.id()))
|
||
.ok_or_else(|| unsupported("an edge with one face"))?;
|
||
let mid = match *s {
|
||
Seg::Line { a, b } => (a + b) * 0.5,
|
||
Seg::Arc { mid, .. } => mid,
|
||
};
|
||
let g = faces.get(wf).ok_or_else(|| KernelError::Failed("wall".into()))?;
|
||
let gn = face_normal(g, mid).ok_or_else(|| unsupported("wall normal"))?;
|
||
if gn.dot(n).abs() > 1.0 - 1e-6 {
|
||
return Err(unsupported("a wall tangent to the face"));
|
||
}
|
||
// Into the face from the loop: left of travel.
|
||
let m = n.cross(s.tangent(mid).ok_or_else(|| unsupported("tangent"))?);
|
||
let sd = if gn.dot(m) > 0.0 { 1.0 } else { -1.0 };
|
||
if side != 0.0 && sd != side {
|
||
return Err(unsupported("a loop that is partly convex and partly concave"));
|
||
}
|
||
side = sd;
|
||
walls.push(wf);
|
||
}
|
||
// A run ends in the cross-section through its end vertex: the face there must hold it (a
|
||
// plane square to the run). One at an angle cuts the blend's two contacts at different
|
||
// places, which the rolling-ball blend handles.
|
||
if open {
|
||
let ends = [
|
||
(edges.first().map(|e| e.front().clone()), segs.first().map(|s| (s.start(), *s))),
|
||
(edges.last().map(|e| e.back().clone()), segs.last().map(|s| (s.end(), *s))),
|
||
];
|
||
for (v, seg) in ends {
|
||
let (Some(v), Some((p, s))) = (v, seg) else { return Err(KernelError::Failed("run end".into())) };
|
||
let t = s.tangent(p).ok_or_else(|| unsupported("degenerate loop edge"))?;
|
||
let square = faces
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(gi, g)| *gi != fi && !walls.contains(gi) && g.vertex_iter().any(|x| x == v))
|
||
.all(|(_, g)| plane_normal(g).is_some_and(|gn| gn.dot(t).abs() > 1.0 - 1e-6));
|
||
if !square {
|
||
return Err(unsupported("a run of edges ending at a face at an angle"));
|
||
}
|
||
}
|
||
}
|
||
// Cross-section at a loop point: into the face (m) and up the wall (t, away from the edge,
|
||
// up a concave wall and down a convex one). The ball of radius r touches both at the
|
||
// setback s = r / tan(φ/2), φ the angle between m and t; its centre is on the bisector.
|
||
let into = |s: &Seg, p: Vec3| -> Vec3 { s.tangent(p).map(|t| n.cross(t)).unwrap_or(Vec3::ZERO) };
|
||
let up = |i: usize, p: Vec3| -> Option<Vec3> {
|
||
let s = segs.get(i)?;
|
||
let g = faces.get(*walls.get(i)?)?;
|
||
let gn = face_normal(g, p)?;
|
||
let t = s.tangent(p)?.cross(gn).normalized()?;
|
||
Some(if t.dot(n * side) < 0.0 { -t } else { t })
|
||
};
|
||
let frame = |i: usize, p: Vec3| -> Option<(Vec3, Vec3, f64, f64)> {
|
||
let s = segs.get(i)?;
|
||
let (m, t) = (into(s, p), up(i, p)?);
|
||
let phi = m.dot(t).clamp(-1.0, 1.0).acos();
|
||
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) {
|
||
return None;
|
||
}
|
||
let sb = if round { r / (phi / 2.0).tan() } else { r };
|
||
Some((m, t, sb, r / (phi / 2.0).sin()))
|
||
};
|
||
// The straight seam between the walls at a sharp joint (direction away from the face).
|
||
let seam_dir = |i: usize, p: Vec3| -> Option<Vec3> {
|
||
let o = owner.get(i)?;
|
||
let v = edges.get(*o)?.front().clone();
|
||
let loop_ids: Vec<mt::EdgeID> = edges.iter().map(|e| e.id()).collect();
|
||
let mut dirs = Vec::new();
|
||
for g in faces {
|
||
for e in g.edge_iter() {
|
||
if loop_ids.contains(&e.id()) || !(*e.front() == v || *e.back() == v) {
|
||
continue;
|
||
}
|
||
let other = if *e.front() == v { e.back().clone() } else { e.front().clone() };
|
||
if let Some(d) = (from_p3(other.point()) - p).normalized()
|
||
&& !dirs.iter().any(|x: &Vec3| x.dot(d) > 1.0 - 1e-9)
|
||
{
|
||
dirs.push(d);
|
||
}
|
||
}
|
||
}
|
||
match dirs[..] {
|
||
[d] => Some(d),
|
||
_ => None,
|
||
}
|
||
};
|
||
let mut pa = Vec::with_capacity(nj);
|
||
let mut pb = Vec::with_capacity(nj);
|
||
let mut centres = Vec::with_capacity(nj);
|
||
// Joint i: the start of piece i (the end of the last piece for a run's far end).
|
||
let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) };
|
||
for i in 0..nj {
|
||
let p = joint_point(i).unwrap_or_default();
|
||
let (m, t, sb, dc) = frame(i.min(k - 1), p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||
centres.push(p + (m + t).normalized().unwrap_or(m) * dc);
|
||
if !mitre.get(i).copied().unwrap_or(false) {
|
||
pa.push(p + m * sb);
|
||
pb.push(p + t * sb);
|
||
continue;
|
||
}
|
||
// Sharp joint: the face rails meet where the two offset lines cross; the wall rails
|
||
// meet on the seam.
|
||
let ip = (i + k - 1) % k;
|
||
let (m0, t0, sb0, _) = frame(ip, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||
let (Some(d0), Some(d1)) = (segs.get(ip).and_then(|x| x.tangent(p)), segs.get(i).and_then(|x| x.tangent(p))) else {
|
||
return Err(unsupported("degenerate loop edge"));
|
||
};
|
||
let x = d0.cross(d1);
|
||
let lam = ((m * sb - m0 * sb0).cross(d1)).dot(x) / x.len2();
|
||
pa.push(p + m0 * sb0 + d0 * lam);
|
||
let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?;
|
||
let (h0, h1) = (e.dot(t0), e.dot(t));
|
||
if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 {
|
||
return Err(unsupported("a sharp corner whose walls meet the blend differently"));
|
||
}
|
||
pb.push(p + e * (sb / h1));
|
||
}
|
||
// Sharp joints between straight rim edges can extend beyond the old corner.
|
||
// Equal wall slopes reach this path instead of the corner builder, and must
|
||
// also reject an extension through the outer wall or another hole.
|
||
for (i, q) in pa.iter().enumerate() {
|
||
// Smooth arc transitions and open-run ends retain their existing checks.
|
||
if !mitre.get(i).copied().unwrap_or(false) {
|
||
continue;
|
||
}
|
||
let p = joint_point(i).ok_or_else(|| unsupported("degenerate loop joint"))?;
|
||
if !crate::blend::extension_fits(f, p, *q, size * 1e-7) {
|
||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||
}
|
||
}
|
||
// The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks
|
||
// to a point (a rounded corner rounded again: the blend there is a sphere).
|
||
let mut collapsed = vec![false; k.max(nj)];
|
||
for (i, s) in segs.iter().enumerate() {
|
||
if let Seg::Arc { a, c, .. } = *s {
|
||
let toward = into(s, a).dot(c - a) > 0.0;
|
||
if toward && (a.dist(c) - r).abs() <= tol * 10.0 && !mitre.get(i).copied().unwrap_or(true) && !mitre.get(nxt(i)).copied().unwrap_or(true)
|
||
{
|
||
if let Some(x) = collapsed.get_mut(i) {
|
||
*x = true;
|
||
}
|
||
} else if toward && a.dist(c) <= r + tol {
|
||
return Err(too_large("the blend is larger than a corner radius"));
|
||
}
|
||
}
|
||
}
|
||
if collapsed.iter().all(|x| *x) {
|
||
return Err(too_large("the blend is larger than a corner radius"));
|
||
}
|
||
let mut va: Vec<mt::Vertex> = pa.iter().map(|p| builder::vertex(p3(*p))).collect();
|
||
// A collapsed piece starts and ends at one vertex.
|
||
for i in 0..k {
|
||
if collapsed.get(i).copied().unwrap_or(false)
|
||
&& let Some(v) = va.get(i).cloned()
|
||
&& let Some(slot) = va.get_mut(nxt(i))
|
||
{
|
||
*slot = v;
|
||
}
|
||
}
|
||
// Model vertices at the joints (the second half of a circle starts at none).
|
||
let mut joint_vertex: Vec<Option<mt::Vertex>> = (0..k)
|
||
.map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None })
|
||
.collect();
|
||
if open {
|
||
joint_vertex.push(edges.last().map(|e| e.back().clone()));
|
||
}
|
||
let loop_ids: Vec<mt::EdgeID> = edges.iter().map(|e| e.id()).collect();
|
||
let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id());
|
||
// A blend as tall as its walls uses them up: each wall seam ends exactly where the
|
||
// wall-side rail would, at a vertex of the walls' far boundary, which becomes the rail.
|
||
let wall_end: Vec<Option<mt::Vertex>> = joint_vertex
|
||
.iter()
|
||
.zip(&pb)
|
||
.map(|(v, target)| {
|
||
let v = v.as_ref()?;
|
||
faces.iter().flat_map(|g| g.edge_iter()).find_map(|e| {
|
||
if loop_ids.contains(&e.id()) || on_face(&e) || !(e.front() == v || e.back() == v) || !matches!(e.curve(), mt::Curve::Line(_)) {
|
||
return None;
|
||
}
|
||
let other = if e.front() == v { e.back().clone() } else { e.front().clone() };
|
||
(from_p3(other.point()).dist(*target) <= tol * 10.0).then_some(other)
|
||
})
|
||
})
|
||
.collect();
|
||
let used_up = wall_end.iter().filter(|x| x.is_some()).count();
|
||
if used_up > 0 && (used_up != nj || open) {
|
||
return Err(unsupported("the blend uses up only part of the walls"));
|
||
}
|
||
let walls_used_up = used_up > 0;
|
||
let vb: Vec<mt::Vertex> = match walls_used_up {
|
||
true => wall_end.into_iter().flatten().collect(),
|
||
false => pb.iter().map(|p| builder::vertex(p3(*p))).collect(),
|
||
};
|
||
let centre = |i: usize| centres.get(i).copied().unwrap_or_default();
|
||
let profile_mid = |i: usize| {
|
||
let (Some(p), c) = (joint_point(i), centre(i)) else { return Vec3::ZERO };
|
||
c + (p - c).normalized().unwrap_or(n) * r
|
||
};
|
||
// Profiles at the joints (face side → wall side).
|
||
let mut profiles: Vec<mt::Edge> = Vec::with_capacity(nj);
|
||
for i in 0..nj {
|
||
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) };
|
||
if !round {
|
||
profiles.push(builder::line(a, b));
|
||
continue;
|
||
}
|
||
if !mitre.get(i).copied().unwrap_or(false) {
|
||
profiles.push(builder::circle_arc(a, b, p3(profile_mid(i))));
|
||
continue;
|
||
}
|
||
// Mitre: the cross-section circle of this edge's blend where it passes the face-side
|
||
// point, slid along the edge onto the mitre plane (an ellipse arc, on both blends).
|
||
let p = segs.get(i).map(|x| x.start()).unwrap_or_default();
|
||
let d1 = segs.get(i).and_then(|x| x.tangent(p)).ok_or_else(|| unsupported("tangent"))?;
|
||
let (pa_i, pb_i) = (from_p3(a.point()), from_p3(b.point()));
|
||
let lam = (pa_i - p).dot(d1);
|
||
let (_, t, sb, _) = frame(i, p).ok_or_else(|| unsupported("frame"))?;
|
||
let cc = centre(i) + d1 * lam;
|
||
let q = p + t * sb + d1 * lam;
|
||
let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r;
|
||
let arc = builder::circle_arc(&builder::vertex(p3(pa_i)), &builder::vertex(p3(q)), p3(mid));
|
||
let nb = (pa_i - p).cross(pb_i - p).normalized().ok_or_else(|| unsupported("mitre plane"))?;
|
||
let den = d1.dot(nb);
|
||
if den.abs() < 1e-9 {
|
||
return Err(unsupported("mitre plane along the edge"));
|
||
}
|
||
// x ↦ x − d1 ((x − p)·nb) / (d1·nb), column-major.
|
||
let col = |j: usize| {
|
||
let ej = [Vec3::X, Vec3::Y, Vec3::Z][j];
|
||
ej - d1 * (ej.dot(nb) / den)
|
||
};
|
||
let (c0, c1, c2) = (col(0), col(1), col(2));
|
||
let tr = d1 * (p.dot(nb) / den);
|
||
let mat = mt::Matrix4::new(c0.x, c0.y, c0.z, 0.0, c1.x, c1.y, c1.z, 0.0, c2.x, c2.y, c2.z, 0.0, tr.x, tr.y, tr.z, 1.0);
|
||
let mut curve = arc.curve();
|
||
mt::Transformed::transform_by(&mut curve, mat);
|
||
profiles.push(mt::Edge::new(a, b, curve));
|
||
}
|
||
let rail = |vs: &[mt::Vertex], i: usize, offset: &dyn Fn(Vec3, &Seg) -> Vec3| -> Option<mt::Edge> {
|
||
let s = segs.get(i)?;
|
||
if std::ptr::eq(vs, va.as_slice()) && collapsed.get(i).copied().unwrap_or(false) {
|
||
return None;
|
||
}
|
||
let (a, b) = (vs.get(i)?, vs.get(nxt(i))?);
|
||
Some(match *s {
|
||
Seg::Line { .. } => builder::line(a, b),
|
||
Seg::Arc { mid, .. } => builder::circle_arc(a, b, p3(offset(mid, s))),
|
||
})
|
||
};
|
||
let piece_of = |s: &Seg| segs.iter().position(|x| std::ptr::eq(x, s)).unwrap_or(0);
|
||
let face_off = |p: Vec3, s: &Seg| {
|
||
let i = piece_of(s);
|
||
frame(i, p).map(|(m, _, sb, _)| p + m * sb).unwrap_or(p)
|
||
};
|
||
let wall_off = |p: Vec3, s: &Seg| {
|
||
let i = piece_of(s);
|
||
frame(i, p).map(|(_, t, sb, _)| p + t * sb).unwrap_or(p)
|
||
};
|
||
let face_rails: Vec<Option<mt::Edge>> = (0..k).map(|i| rail(&va, i, &face_off)).collect();
|
||
if face_rails.iter().zip(&collapsed).any(|(r, c)| r.is_none() && !c) {
|
||
return Err(KernelError::Failed("rail".into()));
|
||
}
|
||
let wall_rails: Vec<mt::Edge> = if walls_used_up {
|
||
// Each piece's wall rail is the wall's far edge between the joints, where the offset
|
||
// rail lies (checked at the piece's middle).
|
||
(0..k)
|
||
.map(|i| -> Result<mt::Edge> {
|
||
let (Some(a), Some(b), Some(s), Some(wf)) = (vb.get(i), vb.get(nxt(i)), segs.get(i), walls.get(i)) else {
|
||
return Err(KernelError::Failed("rail".into()));
|
||
};
|
||
let mid = match *s {
|
||
Seg::Line { a, b } => (a + b) * 0.5,
|
||
Seg::Arc { mid, .. } => mid,
|
||
};
|
||
let want = wall_off(mid, s);
|
||
let g = faces.get(*wf).ok_or_else(|| KernelError::Failed("wall".into()))?;
|
||
g.edge_iter()
|
||
.find(|e| (e.front() == a && e.back() == b) || (e.front() == b && e.back() == a))
|
||
.filter(|e| crate::blend::edge_mid(e).is_some_and(|m| m.dist(want) <= tol * 10.0))
|
||
.map(|e| if e.front() == a { e.clone() } else { e.inverse() })
|
||
.ok_or_else(|| unsupported("the blend uses up walls whose far edge is not where the blend ends"))
|
||
})
|
||
.collect::<Result<_>>()?
|
||
} else {
|
||
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?
|
||
};
|
||
// Seams between neighbouring walls at the joints get shorter.
|
||
// At a run's ends the face's next loop edges stop at the face-side point.
|
||
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||
let mut seam_ends: HashMap<mt::EdgeID, (mt::Edge, Option<mt::Vertex>, Option<mt::Vertex>)> = HashMap::new();
|
||
for (i, v) in joint_vertex.iter().enumerate() {
|
||
let Some(v) = v else { continue };
|
||
for g in faces {
|
||
for e in g.edge_iter() {
|
||
if loop_ids.contains(&e.id()) || !(e.front() == v || e.back() == v) {
|
||
continue;
|
||
}
|
||
// Once per edge and joint (an edge between both ends of a run is met twice).
|
||
if seam_ends.get(&e.id()).is_some_and(|x: &(mt::Edge, Option<mt::Vertex>, Option<mt::Vertex>)| {
|
||
(x.0.absolute_front() == v && x.1.is_some()) || (x.0.absolute_back() == v && x.2.is_some())
|
||
}) {
|
||
continue;
|
||
}
|
||
let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone());
|
||
let other = if &fa == v { &fb } else { &fa };
|
||
if !is_straight(&e, (size * 1e-5).max(1e-6)) {
|
||
return Err(unsupported("the wall seams must be straight"));
|
||
}
|
||
let face_side = on_face(&e);
|
||
// A used-up wall goes, seams and all.
|
||
if walls_used_up && !face_side {
|
||
continue;
|
||
}
|
||
let target = if face_side { pa.get(i) } else { pb.get(i) }.copied().unwrap_or_default();
|
||
let along = (from_p3(other.point()) - from_p3(v.point())).dot(target - from_p3(v.point()));
|
||
let reach = (target - from_p3(v.point())).len2();
|
||
if along <= reach + tol {
|
||
return Err(too_large(if face_side { "the blend is longer than the next edge" } else { "the blend is taller than the walls" }));
|
||
}
|
||
let nb = if face_side { va.get(i) } else { vb.get(i) }.ok_or_else(|| KernelError::Failed("joint".into()))?;
|
||
let slot = seam_ends.entry(e.id()).or_insert_with(|| (e.absolute_clone(), None, None));
|
||
if &fa == v {
|
||
slot.1 = Some(nb.clone());
|
||
} else {
|
||
slot.2 = Some(nb.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (id, (e, a, b)) in seam_ends {
|
||
let a = a.unwrap_or_else(|| e.absolute_front().clone());
|
||
let b = b.unwrap_or_else(|| e.absolute_back().clone());
|
||
if from_p3(a.point()).dist(from_p3(b.point())) < tol * 10.0 {
|
||
return Err(too_large("the blend is longer than the next edge"));
|
||
}
|
||
seam_subst.insert(id, builder::line(&a, &b));
|
||
}
|
||
// Rebuild faces.
|
||
let idx_of = |id: mt::EdgeID| loop_ids.iter().position(|x| *x == id);
|
||
let rebuild = |g: &mt::Face, rails: &[Option<mt::Edge>]| -> Result<mt::Face> {
|
||
let mut wires = Vec::new();
|
||
for w in g.absolute_boundaries() {
|
||
let mut es = Vec::new();
|
||
for e in w.edge_iter() {
|
||
if let Some(i) = idx_of(e.id()) {
|
||
// Rails run in loop order; an edge used the other way takes them reversed.
|
||
let mine: Vec<&mt::Edge> = owner.iter().zip(rails).filter(|(o, _)| **o == i).filter_map(|(_, r)| r.as_ref()).collect();
|
||
let same = edges.get(i).is_some_and(|le| le.orientation() == e.orientation());
|
||
if same {
|
||
es.extend(mine.into_iter().cloned());
|
||
} else {
|
||
es.extend(mine.into_iter().rev().map(|r| r.inverse()));
|
||
}
|
||
continue;
|
||
}
|
||
let ne = if let Some(s) = seam_subst.get(&e.id()) {
|
||
if e.front() == e.absolute_front() { s.clone() } else { s.inverse() }
|
||
} else {
|
||
e.clone()
|
||
};
|
||
es.push(ne);
|
||
}
|
||
// A run's end: the face's edge and the wall's seam now stop at the two ends of the
|
||
// end profile, which closes the gap between them.
|
||
if open {
|
||
let mut fixed: Vec<mt::Edge> = Vec::with_capacity(es.len() + 2);
|
||
let m = es.len();
|
||
for j in 0..m {
|
||
let (Some(cur), Some(next)) = (es.get(j), es.get((j + 1) % m)) else { continue };
|
||
fixed.push(cur.clone());
|
||
if cur.back() == next.front() {
|
||
continue;
|
||
}
|
||
for ji in [0, nj - 1] {
|
||
let (Some(a), Some(b), Some(pr)) = (va.get(ji), vb.get(ji), profiles.get(ji)) else { continue };
|
||
if cur.back() == a && next.front() == b {
|
||
fixed.push(pr.clone());
|
||
} else if cur.back() == b && next.front() == a {
|
||
fixed.push(pr.inverse());
|
||
}
|
||
}
|
||
}
|
||
es = fixed;
|
||
}
|
||
wires.push(mt::Wire::from(es));
|
||
}
|
||
crate::heal::absolute_face(g, wires).ok_or_else(|| KernelError::Failed("loop blend: a face could not be rebuilt".into()))
|
||
};
|
||
let mut out: Vec<mt::Face> = Vec::new();
|
||
let mut rec = Rebuild::default();
|
||
rec.pieces(&seam_subst);
|
||
for (gi, g) in faces.iter().enumerate() {
|
||
let rebuilt = if gi == fi {
|
||
rebuild(g, &face_rails)?
|
||
} else if walls.contains(&gi) {
|
||
if walls_used_up {
|
||
continue;
|
||
}
|
||
let wr: Vec<Option<mt::Edge>> = wall_rails.iter().cloned().map(Some).collect();
|
||
rebuild(g, &wr)?
|
||
} else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) {
|
||
rebuild(g, &[])?
|
||
} else {
|
||
(*g).clone()
|
||
};
|
||
rec.face(&rebuilt, g);
|
||
out.push(rebuilt);
|
||
}
|
||
// Blend faces.
|
||
use mt::{ParametricSurface3D, SearchNearestParameter};
|
||
for i in 0..k {
|
||
let (Some(s), Some(pr), Some(pr_next), Some(fr), Some(wr)) =
|
||
(segs.get(i), profiles.get(i), profiles.get(nxt(i)), face_rails.get(i), wall_rails.get(i))
|
||
else {
|
||
continue;
|
||
};
|
||
let swept: mt::Shell = match *s {
|
||
Seg::Line { a, b } => {
|
||
// The cross-section at the start, swept along the whole edge and beyond (the
|
||
// face may reach past the edge's ends at a mitre).
|
||
let d = (b - a).normalized().ok_or_else(|| unsupported("degenerate loop edge"))?;
|
||
let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||
let m = into(s, a);
|
||
let ext = size;
|
||
let back = d * -ext;
|
||
let (fa, wa) = (builder::vertex(p3(a + m * sb + back)), builder::vertex(p3(a + t * sb + back)));
|
||
let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i) + back)) } else { builder::line(&fa, &wa) };
|
||
vec![builder::tsweep(&base, v3(b - a + d * (2.0 * ext)))].into()
|
||
}
|
||
Seg::Arc { .. } if collapsed.get(i).copied().unwrap_or(false) => {
|
||
// A sphere about the profile's centre, its poles well away from the corner
|
||
// patch (a pole inside a face's corner confuses the meshing).
|
||
let c = centre(i);
|
||
let corners = [from_p3(pr.front().point()), from_p3(pr.back().point()), from_p3(pr_next.back().point())];
|
||
let g = corners.iter().fold(Vec3::ZERO, |acc, q| acc + (*q - c).normalized().unwrap_or(Vec3::ZERO));
|
||
let g = g.normalized().ok_or_else(|| unsupported("sphere corner"))?;
|
||
let w = g.any_perp();
|
||
let (top, eq, bottom) = (builder::vertex(p3(c + w * r)), p3(c + g * r), builder::vertex(p3(c - w * r)));
|
||
let meridian = builder::circle_arc(&top, &bottom, eq);
|
||
builder::rsweep(&meridian, p3(c), v3(w), mt::Rad(std::f64::consts::TAU))
|
||
}
|
||
Seg::Arc { c, axis, angle, .. } => builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)),
|
||
};
|
||
let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?;
|
||
// Outward: toward the profile centre on a concave loop, away from it on a convex one.
|
||
let (c, mid) = (centre(i), profile_mid(i));
|
||
let outward = (c - mid) * side;
|
||
if let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) {
|
||
let nn = surf.normal(u, v);
|
||
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
|
||
surf = mt::Invertible::inverse(&surf);
|
||
}
|
||
}
|
||
let w: mt::Wire = match fr {
|
||
Some(fr) => vec![fr.inverse(), pr.clone(), wr.clone(), pr_next.inverse()].into(),
|
||
None => vec![pr.clone(), wr.clone(), pr_next.inverse()].into(),
|
||
};
|
||
let face = mt::Face::try_new(vec![w.clone()], surf.clone()).map_err(|e| KernelError::Failed(format!("blend face: {e}")))?;
|
||
// In place of the loop edge this piece runs along (a full circle's two halves both).
|
||
rec.blend(&face, owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.id()));
|
||
out.push(face);
|
||
}
|
||
let mut shells = solid.boundaries().clone();
|
||
if let Some(sh) = shells.get_mut(si) {
|
||
*sh = out.into();
|
||
}
|
||
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("loop blend produced an invalid solid: {e}")))?;
|
||
Ok((solid, rec))
|
||
}
|