mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
kernel: the rolling-ball, corner, loop and vertex blend builders are gone
curveblend.rs (rolling-ball rounds and chamfers of curved edges, their corners, the law and chord variants), curvecorner.rs, loopblend.rs (blends round whole face loops) and blend_vertex.rs (a sphere at three rounded edges) built blends on truck; SMLib's blend makes all of these in its one call, with its own corners. curvecorner_tests.rs tests fillet and chamfer through the public functions only, so it stays, mounted from blend.rs. curveblend_tests.rs and curveblend_law_tests.rs go with the builder: they test its internals (fit_sections, curve_blend_with_limit, ROLLS, its NURBS surfaces and contacts). lib.rs needs `mod curveblend;`, `mod loopblend;` and `mod blend_vertex;` removed. (not built: base in progress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
@@ -769,3 +769,7 @@ mod tests {
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "curvecorner_tests.rs"]
|
||||
mod corner_tests;
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
//! Fillets of the three edges at a convex vertex of any smooth faces, of one radius: the rounds
|
||||
//! end in a piece of sphere, as they do at the corner of a box.
|
||||
//!
|
||||
//! The ball of the fillets' radius that touches the three faces there (its centre `c`, its
|
||||
//! contacts `q`) ends each round: round `k` stops at the circle where the ball meets the plane
|
||||
//! through `c` and the contacts of its two faces, and the sphere's patch joins the three circles.
|
||||
//! The construction uses only what a round with a plane end does already:
|
||||
//! 1. cut off the corner beyond the three planes (a skewed box whose three faces are those
|
||||
//! planes), so each of the three edges ends square on a plane face;
|
||||
//! 2. round the three edges one after another, each ending in its plane face, which a round's
|
||||
//! end arc and the sides it uses up reduce to a quarter disc of the ball's section;
|
||||
//! 3. replace the three quarter discs, which meet along radii at `c`, by the sphere's patch,
|
||||
//! which has the three end arcs for its boundary.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt};
|
||||
|
||||
use crate::blend::{edge_mid, too_large, unsupported, vtx};
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::curveblend::project;
|
||||
use crate::record;
|
||||
use crate::{BoolOp, KernelError, Result};
|
||||
|
||||
/// Round the edges nearest the points, `radius` each. The three edges meeting at a vertex that
|
||||
/// all lie among them end in a sphere; the others are rounded as they would be alone.
|
||||
pub(crate) fn fillet_ball_vertices(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
|
||||
return Err(KernelError::Invalid("fillet size must be positive".into()));
|
||||
}
|
||||
if edges.len() < 3 {
|
||||
return Err(unsupported("a vertex with three rounded edges"));
|
||||
}
|
||||
let counts = record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::healed(body)?;
|
||||
let mut todo: Vec<Vec3> = edges.to_vec();
|
||||
let mut done = false;
|
||||
while let Some((at, picks)) = find_vertex(&cur, &todo)? {
|
||||
let next = round_vertex(&cur, at, radius)?;
|
||||
cur = record::after(next, &cur, &counts);
|
||||
todo.retain(|p| !picks.iter().any(|q| q.dist(*p) < 1e-12));
|
||||
done = true;
|
||||
}
|
||||
if !done {
|
||||
return Err(unsupported("a vertex with three rounded edges"));
|
||||
}
|
||||
if todo.is_empty() { Ok(cur) } else { crate::blend::fillet(&cur, &todo, radius) }
|
||||
}
|
||||
|
||||
/// A vertex of three edges all among the picked ones (its position) and their picks.
|
||||
fn find_vertex(cur: &Body, picks: &[Vec3]) -> Result<Option<(Vec3, Vec<Vec3>)>> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let solid = cur.deep_copy();
|
||||
let all = Body::unique_edges(&solid);
|
||||
// The picked edges, by identity, with the pick each came from.
|
||||
let mut picked: Vec<(mt::EdgeID, Vec3)> = Vec::new();
|
||||
for p in picks {
|
||||
let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Ok(None) };
|
||||
if dist > size * 0.05 + 1e-3 {
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
}
|
||||
if let Some(e) = all.get(idx)
|
||||
&& !picked.iter().any(|(id, _)| *id == e.id())
|
||||
{
|
||||
picked.push((e.id(), *p));
|
||||
}
|
||||
}
|
||||
let mut at_vertex: HashMap<mt::VertexID, Vec<(mt::EdgeID, Vec3)>> = HashMap::new();
|
||||
for e in &all {
|
||||
let Some((_, p)) = picked.iter().find(|(id, _)| *id == e.id()) else { continue };
|
||||
for v in [e.absolute_front(), e.absolute_back()] {
|
||||
at_vertex.entry(v.id()).or_default().push((e.id(), *p));
|
||||
}
|
||||
}
|
||||
for (vid, list) in at_vertex {
|
||||
let degree = all.iter().filter(|e| e.absolute_front().id() == vid || e.absolute_back().id() == vid).count();
|
||||
if list.len() == 3 && degree == 3 {
|
||||
let v = all.iter().find_map(|e| [e.absolute_front(), e.absolute_back()].into_iter().find(|v| v.id() == vid));
|
||||
if let Some(v) = v {
|
||||
return Ok(Some((vtx(v), list.iter().map(|(_, p)| *p).collect())));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The ball touching three faces from inside, near the corner `v`: its centre and contacts.
|
||||
struct Ball {
|
||||
c: Vec3,
|
||||
/// Contact with each face, and the face's outward normal there.
|
||||
q: [Vec3; 3],
|
||||
}
|
||||
|
||||
fn ball_at(faces: &[&mt::Face; 3], v: Vec3, r: f64, tol: f64) -> Result<Ball> {
|
||||
let rows = |n: [Vec3; 3]| [[n[0].x, n[0].y, n[0].z], [n[1].x, n[1].y, n[1].z], [n[2].x, n[2].y, n[2].z]];
|
||||
let near = |p: Vec3, hint: &mut [Option<(f64, f64)>; 3]| -> Option<([Vec3; 3], [Vec3; 3])> {
|
||||
let mut q = [Vec3::ZERO; 3];
|
||||
let mut n = [Vec3::ZERO; 3];
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let (qi, ni, uv) = project(f, p, hint[i])?;
|
||||
(q[i], n[i], hint[i]) = (qi, ni, Some(uv));
|
||||
}
|
||||
Some((q, n))
|
||||
};
|
||||
let mut hint = [None; 3];
|
||||
let (_, n0) = near(v, &mut hint).ok_or_else(|| unsupported("the faces at the vertex"))?;
|
||||
// The planes' ball first: each face r inside its tangent plane at the vertex.
|
||||
let x = crate::polyhedron::solve3_pub(rows(n0), [-r, -r, -r]).ok_or_else(|| unsupported("a vertex whose faces do not meet in a corner"))?;
|
||||
let mut c = v + Vec3::new(x[0], x[1], x[2]);
|
||||
for _ in 0..60 {
|
||||
let (q, n) = near(c, &mut hint).ok_or_else(|| unsupported("the ball at the vertex"))?;
|
||||
let g = [(c - q[0]).dot(n[0]) + r, (c - q[1]).dot(n[1]) + r, (c - q[2]).dot(n[2]) + r];
|
||||
let d = crate::polyhedron::solve3_pub(rows(n), [-g[0], -g[1], -g[2]])
|
||||
.ok_or_else(|| unsupported("a vertex whose faces do not meet in a corner"))?;
|
||||
let dc = Vec3::new(d[0], d[1], d[2]);
|
||||
c += dc;
|
||||
if !c.is_finite() {
|
||||
break;
|
||||
}
|
||||
if dc.len() < tol * 1e-3 && g.iter().all(|x| x.abs() < tol) {
|
||||
let (q, n) = near(c, &mut hint).ok_or_else(|| unsupported("the ball at the vertex"))?;
|
||||
// A convex corner: the vertex lies out from every face the ball touches.
|
||||
if n.iter().any(|ni| (v - c).dot(*ni) < 0.25 * r) {
|
||||
return Err(unsupported("rounds at a corner that is not convex"));
|
||||
}
|
||||
return Ok(Ball { c, q });
|
||||
}
|
||||
}
|
||||
Err(unsupported("the ball does not fit at the vertex"))
|
||||
}
|
||||
|
||||
/// Round the three edges at the vertex at `at`, ending in a sphere.
|
||||
fn round_vertex(cur: &Body, at: Vec3, r: f64) -> Result<Body> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let solid = cur.deep_copy();
|
||||
let all = Body::unique_edges(&solid);
|
||||
let face_list: Vec<&mt::Face> = solid.face_iter().collect();
|
||||
// The vertex, its three edges and the faces between them.
|
||||
let v = all
|
||||
.iter()
|
||||
.flat_map(|e| [e.absolute_front().clone(), e.absolute_back().clone()])
|
||||
.find(|v| vtx(v).dist(at) <= size * 1e-9 + 1e-12)
|
||||
.ok_or_else(|| KernelError::Failed("fillet vertex".into()))?;
|
||||
let edges: Vec<mt::Edge> = all.iter().filter(|e| *e.absolute_front() == v || *e.absolute_back() == v).cloned().collect();
|
||||
let faces_of = |e: &mt::Edge| -> Vec<usize> {
|
||||
(0..face_list.len()).filter(|i| face_list.get(*i).is_some_and(|f| f.edge_iter().any(|x| x.id() == e.id()))).collect()
|
||||
};
|
||||
let mut around: Vec<usize> = Vec::new();
|
||||
for e in &edges {
|
||||
for f in faces_of(e) {
|
||||
if !around.contains(&f) {
|
||||
around.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
let [fa, fb, fc] = around[..] else { return Err(unsupported("a vertex of three faces")) };
|
||||
let (Some(a), Some(b), Some(c)) = (face_list.get(fa), face_list.get(fb), face_list.get(fc)) else {
|
||||
return Err(KernelError::Failed("faces".into()));
|
||||
};
|
||||
let faces = [*a, *b, *c];
|
||||
let ball = ball_at(&faces, vtx(&v), r, tol * 100.0)?;
|
||||
// The plane of each edge's end: through the centre and the contacts of the edge's two faces,
|
||||
// its normal out toward the vertex.
|
||||
let mut normals: Vec<Vec3> = Vec::with_capacity(3);
|
||||
for e in &edges {
|
||||
let sides: Vec<usize> = faces_of(e).iter().filter_map(|f| around.iter().position(|x| x == f)).collect();
|
||||
let [i, j] = sides[..] else { return Err(unsupported("an edge not between two faces")) };
|
||||
let (Some(qi), Some(qj)) = (ball.q.get(i), ball.q.get(j)) else { return Err(KernelError::Failed("contacts".into())) };
|
||||
let m = (*qi - ball.c).cross(*qj - ball.c).normalized().ok_or_else(|| unsupported("a ball that touches an edge's faces at one point"))?;
|
||||
normals.push(if m.dot(vtx(&v) - ball.c) < 0.0 { -m } else { m });
|
||||
}
|
||||
let [m0, m1, m2] = normals[..] else { return Err(KernelError::Failed("planes".into())) };
|
||||
// The corner beyond the three planes, a skewed box from the centre along the three lines
|
||||
// where two of the planes meet, long enough to hold the corner of the body.
|
||||
let gen_ = |x: Vec3, y: Vec3, toward: Vec3| -> Result<Vec3> {
|
||||
let g = x.cross(y).normalized().ok_or_else(|| unsupported("planes that do not meet in a corner"))?;
|
||||
Ok(if g.dot(toward) < 0.0 { -g } else { g })
|
||||
};
|
||||
let reach = 3.0 * (vtx(&v).dist(ball.c) + r);
|
||||
let (g0, g1, g2) = (gen_(m1, m2, m0)? * reach, gen_(m2, m0, m1)? * reach, gen_(m0, m1, m2)? * reach);
|
||||
let unit = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0))?;
|
||||
let m = [[g0.x, g0.y, g0.z, 0.0], [g1.x, g1.y, g1.z, 0.0], [g2.x, g2.y, g2.z, 0.0], [ball.c.x, ball.c.y, ball.c.z, 1.0]];
|
||||
let cutter = crate::ops::transform_matrix(&unit, m)?;
|
||||
let notched = crate::ops::boolean(cur, &cutter, BoolOp::Cut)?.ok_or_else(|| unsupported("a corner this small"))?;
|
||||
// The cut splits a curved face along the trace of its plane (the whole way across, not just
|
||||
// where the plane cuts): the pieces are one face again.
|
||||
let notched = crate::guard("merge split faces", || merge_split_faces(¬ched, cur, size))?;
|
||||
// A point on what is left of each edge, half way from where its plane crosses it to its far end.
|
||||
let mut picks: Vec<Vec3> = Vec::with_capacity(3);
|
||||
for (e, m) in edges.iter().zip(&normals) {
|
||||
picks.push(remaining_pick(e, &v, ball.c, *m)?);
|
||||
}
|
||||
// Round them one after another, in an order that works.
|
||||
let mut last: Option<KernelError> = None;
|
||||
for order in [[0usize, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
|
||||
let mut step = notched.clone();
|
||||
let mut ok = true;
|
||||
for k in order {
|
||||
let Some(p) = picks.get(k) else { return Err(KernelError::Failed("pick".into())) };
|
||||
match crate::blend::fillet(&step, &[*p], r) {
|
||||
Ok(b) => step = b,
|
||||
Err(e) => {
|
||||
last = Some(e);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return close_with_sphere(&step, &ball, [m0, m1, m2], r, vtx(&v));
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| unsupported("the three rounds at a vertex")))
|
||||
}
|
||||
|
||||
/// A point on edge `e` between where the plane (through `c`, normal `m`) crosses it and its far
|
||||
/// end (the end that is not `v`).
|
||||
fn remaining_pick(e: &mt::Edge, v: &mt::Vertex, c: Vec3, m: Vec3) -> Result<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let curve = e.curve();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
// From the end at the vertex (outside the plane's far side) to the far end.
|
||||
let (near, far) = if e.absolute_front() == v { (lo, hi) } else { (hi, lo) };
|
||||
let side = |t: f64| (from_p3(curve.subs(t)) - c).dot(m);
|
||||
let (mut a, mut b) = (near, far);
|
||||
if !(side(a) > 0.0 && side(b) < 0.0) {
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
for _ in 0..80 {
|
||||
let mid = 0.5 * (a + b);
|
||||
if side(mid) > 0.0 { a = mid } else { b = mid }
|
||||
}
|
||||
Ok(from_p3(curve.subs(0.5 * (0.5 * (a + b) + far))))
|
||||
}
|
||||
|
||||
/// `step` has the three rounds ending in three quarter discs; the sphere's patch takes their place.
|
||||
fn close_with_sphere(step: &Body, ball: &Ball, planes: [Vec3; 3], r: f64, v: Vec3) -> Result<Body> {
|
||||
let size = step.size();
|
||||
let on_tol = (size * 1e-6).max(1e-9);
|
||||
let solid = step.deep_copy();
|
||||
let faces: Vec<mt::Face> = solid.face_iter().cloned().collect();
|
||||
// The face in each plane: a plane face through the centre.
|
||||
let mut discs: Vec<usize> = Vec::new();
|
||||
for m in planes {
|
||||
let found = faces.iter().position(|f| {
|
||||
crate::blend::plane_normal(f).is_some_and(|n| n.dot(m).abs() > 1.0 - 1e-9)
|
||||
&& f.vertex_iter().next().is_some_and(|p| (vtx(&p) - ball.c).dot(m).abs() <= on_tol * 10.0)
|
||||
&& f.vertex_iter().any(|p| vtx(&p).dist(ball.c) <= on_tol * 10.0)
|
||||
});
|
||||
let Some(i) = found else { return Err(unsupported("the ends of the three rounds")) };
|
||||
discs.push(i);
|
||||
}
|
||||
// The arc of each: its one edge that is not a straight line.
|
||||
let mut arcs: Vec<mt::Edge> = Vec::with_capacity(3);
|
||||
for i in &discs {
|
||||
let f = faces.get(*i).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let curved: Vec<mt::Edge> = f.edge_iter().filter(|e| !matches!(e.curve(), mt::Curve::Line(_))).collect();
|
||||
let [arc] = &curved[..] else { return Err(unsupported("the ends of the three rounds")) };
|
||||
arcs.push(arc.absolute_clone());
|
||||
}
|
||||
// The three arcs in a loop.
|
||||
let first_arc = arcs.first().cloned().ok_or_else(|| KernelError::Failed("arc".into()))?;
|
||||
let mut used: HashSet<mt::EdgeID> = HashSet::from([first_arc.id()]);
|
||||
let mut wire: Vec<mt::Edge> = vec![first_arc];
|
||||
for _ in 0..2 {
|
||||
let end = wire.last().map(|e| e.back().clone()).ok_or_else(|| KernelError::Failed("arc".into()))?;
|
||||
let next = arcs
|
||||
.iter()
|
||||
.find(|a| !used.contains(&a.id()) && (*a.front() == end || *a.back() == end))
|
||||
.ok_or_else(|| unsupported("the ends of the three rounds"))?;
|
||||
used.insert(next.id());
|
||||
wire.push(if *next.front() == end { next.clone() } else { next.inverse() });
|
||||
}
|
||||
if wire.last().map(|e| e.back().clone()) != wire.first().map(|e| e.front().clone()) {
|
||||
return Err(unsupported("the ends of the three rounds"));
|
||||
}
|
||||
// A sphere about the centre, its poles and seam away from the patch.
|
||||
let out = (v - ball.c).normalized().ok_or_else(|| KernelError::Failed("sphere".into()))?;
|
||||
let side = out.any_perp();
|
||||
let (np, sp, back) = (ball.c + side * r, ball.c - side * r, ball.c - out * r);
|
||||
let meridian = mt::builder::circle_arc(&mt::builder::vertex(p3(np)), &mt::builder::vertex(p3(sp)), p3(back));
|
||||
let mut surf =
|
||||
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(ball.c), v3(side))));
|
||||
{
|
||||
use mt::{ParametricSurface3D, SearchNearestParameter};
|
||||
if let Some((u, w)) = surf.search_nearest_parameter(p3(ball.c + out * r), None, 100) {
|
||||
let n = surf.normal(u, w);
|
||||
if Vec3::new(n.x, n.y, n.z).dot(out) < 0.0 {
|
||||
surf = mt::Invertible::inverse(&surf);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The patch runs each arc opposite to the round that ends on it.
|
||||
let rest: Vec<&mt::Face> = faces.iter().enumerate().filter(|(i, _)| !discs.contains(i)).map(|(_, f)| f).collect();
|
||||
let uses = |id: mt::EdgeID| {
|
||||
rest.iter().find_map(|f| {
|
||||
f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).find(|x| x.id() == id).map(|x| x.front().id())
|
||||
})
|
||||
};
|
||||
if let (Some(first), Some(front)) = (wire.first(), wire.first().and_then(|e| uses(e.id())))
|
||||
&& front == first.front().id()
|
||||
{
|
||||
wire = wire.iter().rev().map(|e| e.inverse()).collect();
|
||||
}
|
||||
let patch = mt::Face::try_new(vec![mt::Wire::from(wire)], surf).map_err(|e| KernelError::Failed(format!("sphere patch: {e}")))?;
|
||||
let mut new_faces: Vec<mt::Face> = rest.into_iter().cloned().collect();
|
||||
new_faces.push(patch);
|
||||
let mut shells = solid.boundaries().clone();
|
||||
let si = shells.iter().position(|sh| sh.face_iter().any(|f| discs.iter().any(|i| faces.get(*i).is_some_and(|d| d.id() == f.id())))).unwrap_or(0);
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_faces.into();
|
||||
}
|
||||
let out: Solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("rounds ending in a sphere: {e}")))?;
|
||||
record::rebuilt(out, &[&step.solid], &record::Rebuild::default())
|
||||
}
|
||||
|
||||
/// `body` with the curved faces that are pieces of one input face (as the cut that made `body`
|
||||
/// recorded them) joined again: the edges two pieces share go, the rest make the one boundary.
|
||||
fn merge_split_faces(body: &Body, source: &Body, size: f64) -> Result<Body> {
|
||||
let tol = (size * 1e-6).max(1e-9);
|
||||
let Some(history) = body.history() else { return Ok(body.clone()) };
|
||||
let solid = body.deep_copy();
|
||||
let faces: Vec<mt::Face> = solid.face_iter().cloned().collect();
|
||||
if history.faces.len() != faces.len() {
|
||||
return Ok(body.clone());
|
||||
}
|
||||
let on = |a: &mt::Face, p: Vec3| project(a, p, None).is_some_and(|(q, _, uv)| crate::curveblend::in_range(a, uv) && q.dist(p) <= tol * 10.0);
|
||||
// Pieces by the input face they come from.
|
||||
let mut groups: Vec<Vec<usize>> = Vec::new();
|
||||
for (i, f) in history.faces.iter().enumerate() {
|
||||
let curved = faces.get(i).is_some_and(|x| crate::blend::plane_normal(x).is_none());
|
||||
if !(curved && f.change == crate::history::Change::Split && f.origins.len() == 1) {
|
||||
continue;
|
||||
}
|
||||
match groups.iter_mut().find(|g| g.first().and_then(|k| history.faces.get(*k)).is_some_and(|o| o.origins == f.origins)) {
|
||||
Some(g) => g.push(i),
|
||||
None => groups.push(vec![i]),
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Option<mt::Face>> = faces.iter().cloned().map(Some).collect();
|
||||
let mut merged_any = false;
|
||||
for group in groups.iter().filter(|g| g.len() >= 2) {
|
||||
let pieces: Vec<&mt::Face> = group.iter().filter_map(|i| faces.get(*i)).collect();
|
||||
if pieces.iter().any(|f| f.boundaries().len() != 1) {
|
||||
continue;
|
||||
}
|
||||
// Edges used once, as the pieces walk them.
|
||||
let all: Vec<mt::Edge> =
|
||||
pieces.iter().flat_map(|f| f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).collect::<Vec<_>>()).collect();
|
||||
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
for e in &all {
|
||||
*uses.entry(e.id()).or_default() += 1;
|
||||
}
|
||||
let mut left: Vec<mt::Edge> = all.into_iter().filter(|e| uses.get(&e.id()) == Some(&1)).collect();
|
||||
let Some(start) = left.pop() else { continue };
|
||||
let mut wire = vec![start];
|
||||
while let Some(end) = wire.last().map(|e| e.back().clone()) {
|
||||
if wire.first().is_some_and(|e| *e.front() == end) {
|
||||
break;
|
||||
}
|
||||
let Some(at) = left.iter().position(|e| *e.front() == end) else { break };
|
||||
wire.push(left.swap_remove(at));
|
||||
}
|
||||
// One closed boundary, all of it used.
|
||||
if !left.is_empty() || wire.first().map(|e| e.front().clone()) != wire.last().map(|e| e.back().clone()) {
|
||||
continue;
|
||||
}
|
||||
// A boolean may restrict a piece's surface to its own parameter box. The
|
||||
// joined boundary needs the original face's full patch, not an extrapolation
|
||||
// of one piece (which the bounded display planner correctly refuses).
|
||||
let all_on = |f: &mt::Face| {
|
||||
pieces.iter().all(|p| p.vertex_iter().all(|v| on(f, vtx(&v))) && p.edge_iter().all(|e| edge_mid(&e).is_none_or(|m| on(f, m))))
|
||||
};
|
||||
let original = group.first().and_then(|i| history.faces.get(*i)).and_then(|h| match h.origins.as_slice() {
|
||||
[crate::history::FaceOrigin::Face { operand: 0, face }] => source.solid.face_iter().nth(*face),
|
||||
_ => None,
|
||||
});
|
||||
let Some(like) = original.filter(|f| all_on(f)).or_else(|| pieces.iter().copied().find(|f| all_on(f))) else { continue };
|
||||
let Ok(joined) = mt::Face::try_new(vec![mt::Wire::from(wire)], like.oriented_surface()) else { continue };
|
||||
let Some(first) = group.first() else { continue };
|
||||
for i in group {
|
||||
if let Some(slot) = out.get_mut(*i) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = out.get_mut(*first) {
|
||||
*slot = Some(joined);
|
||||
}
|
||||
merged_any = true;
|
||||
}
|
||||
if !merged_any {
|
||||
return Ok(body.clone());
|
||||
}
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.first_mut() {
|
||||
*sh = out.into_iter().flatten().collect::<Vec<_>>().into();
|
||||
}
|
||||
let rebuilt = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("a corner cut: {e}")))?;
|
||||
record::rebuilt(rebuilt, &[&body.solid], &record::Rebuild::default())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,315 +0,0 @@
|
||||
//! Chord-width and radius-law blends along curved edges: the width across the blend at every
|
||||
//! station, and the radius along the edge, measured on the body the kernel built against values
|
||||
//! worked out from the input geometry alone (the two cylinders' axes, the arc's axis).
|
||||
|
||||
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::*;
|
||||
use crate::blend::vtx;
|
||||
use crate::blend_variable::{RadiusSpline, fillet_chord, fillet_variable, fillet_variable_points, fillet_variable_spline};
|
||||
use crate::{Body, BoolOp, boolean, box_solid, cylinder, fillet, measure, step_export};
|
||||
|
||||
/// An oblique boss: a rod of radius 8 whose axis leans 26.57° from the plate's normal, standing on
|
||||
/// a 5 mm plate. The edge where its side meets the plate's top plane is an ellipse whose two faces
|
||||
/// run from 63° to 117° apart around it.
|
||||
fn oblique_boss() -> (Body, Vec3, Vec3) {
|
||||
let plate = box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 5.0)).unwrap();
|
||||
let base = Vec3::new(0.0, 0.0, -10.0);
|
||||
let axis = Vec3::new(0.5, 0.0, 1.0).normalized().unwrap();
|
||||
let rod = cylinder(base, axis, 8.0, 30.0).unwrap();
|
||||
(boolean(&plate, &rod, BoolOp::Union).unwrap().unwrap(), base, axis)
|
||||
}
|
||||
|
||||
/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a
|
||||
/// cylinder away from the front (the wall curves into the part), otherwise the part is the box
|
||||
/// within the cylinder (the wall curves out). With the bottom arc's midpoint, the axis's base
|
||||
/// point and the side of the wall the material is on (+1 away from the axis, −1 toward it).
|
||||
fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let (axis, op, side) =
|
||||
if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) };
|
||||
let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap();
|
||||
let body = boolean(&block, &tool, op).unwrap().unwrap();
|
||||
(body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side)
|
||||
}
|
||||
|
||||
/// The blend face a blend built over `input`: the fitted surface that is on neither of the faces
|
||||
/// it runs between (a boolean leaves the curved faces it kept as fitted surfaces themselves).
|
||||
fn blend_face(input: &Body, body: &Body) -> Option<mt::Face> {
|
||||
use mt::ParametricSurface;
|
||||
let on_input = |p: Vec3| input.solid.face_iter().any(|f| project(f, p, None).is_some_and(|(q, _, _)| q.dist(p) <= 1e-6));
|
||||
body.solid
|
||||
.face_iter()
|
||||
.filter(|f| matches!(f.oriented_surface(), mt::Surface::NurbsSurface(_)))
|
||||
.find(|f| !on_input(from_p3(f.surface().subs(0.5, 0.5))))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The edges of the blend face's boundary, each once, with the point at the middle of it (the
|
||||
/// blend is bounded by its two contacts and, at each end, an arc or the curve of an end face).
|
||||
fn blend_boundary(input: &Body, body: &Body) -> Vec<(mt::Edge, Vec3)> {
|
||||
let Some(f) = blend_face(input, body) else { return Vec::new() };
|
||||
let mut out: Vec<(mt::Edge, Vec3)> = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
for e in w.edge_iter() {
|
||||
if out.iter().any(|(x, _)| x.id() == e.id()) {
|
||||
continue;
|
||||
}
|
||||
let c = e.oriented_curve();
|
||||
let (lo, hi) = c.range_tuple();
|
||||
out.push((e.absolute_clone(), from_p3(c.subs((lo + hi) * 0.5))));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The edges of a contact: the boundary of the blend face whose middle point `side` picks out.
|
||||
fn contact(input: &Body, body: &Body, side: &dyn Fn(Vec3) -> bool) -> Vec<mt::Edge> {
|
||||
blend_boundary(input, body).into_iter().filter(|(_, m)| side(*m)).map(|(e, _)| e).collect()
|
||||
}
|
||||
|
||||
/// The fraction of the way along `edge` (its absolute front to its back) of the point of it
|
||||
/// nearest `q`.
|
||||
fn along_fraction(edge: &mt::Edge, q: Vec3) -> Option<f64> {
|
||||
let e = edge.absolute_clone();
|
||||
let c = e.oriented_curve();
|
||||
let (lo, hi) = c.range_tuple();
|
||||
let steps = 512;
|
||||
let mut at = vec![from_p3(c.subs(lo))];
|
||||
let mut run = vec![0.0];
|
||||
for i in 1..=steps {
|
||||
let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / steps as f64));
|
||||
run.push(run.last().copied()? + at.last().copied()?.dist(p));
|
||||
at.push(p);
|
||||
}
|
||||
let total = *run.last()?;
|
||||
let d = |i: usize| at.get(i).map(|p| p.dist(q)).unwrap_or(f64::INFINITY);
|
||||
let hint = (0..=steps).min_by(|x, y| d(*x).total_cmp(&d(*y))).unwrap_or(0);
|
||||
let t = c.search_nearest_parameter(p3(q), lo + (hi - lo) * hint as f64 / steps as f64, 100)?;
|
||||
let i = (((t - lo) / (hi - lo)) * steps as f64).clamp(0.0, steps as f64);
|
||||
let (k, f) = (i.floor() as usize, i.fract());
|
||||
let (Some(a), Some(b)) = (run.get(k), run.get((k + 1).min(steps))) else { return None };
|
||||
Some((a + (b - a) * f) / total)
|
||||
}
|
||||
|
||||
/// The azimuth of `p` about the Z axis through `axis`.
|
||||
fn azimuth(axis: Vec3, p: Vec3) -> f64 {
|
||||
(p.y - axis.y).atan2(p.x - axis.x)
|
||||
}
|
||||
|
||||
/// The point of the curves running round the Z axis through `axis` at the azimuth `phi`.
|
||||
fn at_azimuth(edges: &[mt::Edge], axis: Vec3, phi: f64) -> Option<Vec3> {
|
||||
let off = |p: Vec3| {
|
||||
let mut d = azimuth(axis, p) - phi;
|
||||
while d > std::f64::consts::PI {
|
||||
d -= std::f64::consts::TAU;
|
||||
}
|
||||
while d < -std::f64::consts::PI {
|
||||
d += std::f64::consts::TAU;
|
||||
}
|
||||
d
|
||||
};
|
||||
let (mut best, mut best_d) = (Vec3::ZERO, f64::INFINITY);
|
||||
for e in edges {
|
||||
let c = e.oriented_curve();
|
||||
let (lo, hi) = c.range_tuple();
|
||||
let steps = 512;
|
||||
let mut at = lo;
|
||||
let mut dist = f64::INFINITY;
|
||||
for i in 0..=steps {
|
||||
let t = lo + (hi - lo) * i as f64 / steps as f64;
|
||||
let d = off(from_p3(c.subs(t))).abs();
|
||||
if d < dist {
|
||||
(at, dist) = (t, d);
|
||||
}
|
||||
}
|
||||
let step = (hi - lo) / steps as f64;
|
||||
let (mut x0, mut x1) = ((at - step).max(lo), (at + step).min(hi));
|
||||
let g = 0.618_033_988_749_894_9;
|
||||
for _ in 0..80 {
|
||||
let (a, b) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0));
|
||||
if off(from_p3(c.subs(a))).abs() < off(from_p3(c.subs(b))).abs() {
|
||||
x1 = b;
|
||||
} else {
|
||||
x0 = a;
|
||||
}
|
||||
}
|
||||
let t = 0.5 * (x0 + x1);
|
||||
let d = off(from_p3(c.subs(t))).abs();
|
||||
if d < best_d {
|
||||
(best, best_d) = (from_p3(c.subs(t)), d);
|
||||
}
|
||||
}
|
||||
(best_d < 1e-6).then_some(best)
|
||||
}
|
||||
|
||||
/// A chord fillet along the cylinder-into-plane edge of an oblique boss — the two faces run from
|
||||
/// 63° to 117° apart around it. The blend's chain stops at the seams between the patches a boolean
|
||||
/// left of the rod's surface, where the end face is tangent to the rod; the cap that cuts the strip
|
||||
/// back there is only placed for one radius, so a width that varies along the edge is refused
|
||||
/// rather than approximated (the fixed radius of `fillet` along the same edge rolls).
|
||||
#[test]
|
||||
fn a_chord_fillet_along_an_oblique_boss_edge_is_refused() {
|
||||
let (body, _, _) = oblique_boss();
|
||||
let pick = Vec3::new(7.5 + 8.944_271_909_999_16, 0.0, 5.0);
|
||||
// A fixed radius rolls along the same chain: the edge itself is supported.
|
||||
assert!(fillet(&body, &[pick], 2.5).is_ok());
|
||||
let e = fillet_chord(&body, &[pick], 4.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
}
|
||||
|
||||
/// A chord fillet along an arc whose two faces meet at a right angle: the width across the blend
|
||||
/// is the chord, so the round is the one of radius `chord / √2` (checked against the constant
|
||||
/// fillet of that radius) and its two contacts are that far apart at every station.
|
||||
#[test]
|
||||
fn chord_fillet_along_an_arc_edge() {
|
||||
let (chord, r) = (3.0, 3.0 / 2f64.sqrt());
|
||||
let (body, mid, axis, _) = arc_walled_box(false);
|
||||
let blended = fillet_chord(&body, &[mid], chord).unwrap();
|
||||
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||
assert_eq!(blended.unmeshed_faces(), 0);
|
||||
let plain = fillet(&body, &[mid], r).unwrap();
|
||||
let (made, exact) = (measure(&blended).unwrap().volume, measure(&plain).unwrap().volume);
|
||||
assert!((made - exact).abs() < exact * 1e-6, "volume {made} vs {exact}");
|
||||
// The contact on the plane and the one on the wall at the same azimuth: the width across.
|
||||
let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6);
|
||||
let wall = contact(&body, &blended, &|m| m.z.abs() >= 1e-6);
|
||||
assert!(!plane.is_empty() && !wall.is_empty(), "the contacts are not the plane's and the wall's");
|
||||
let mid_azimuth = azimuth(axis, mid);
|
||||
let mut worst: f64 = 0.0f64;
|
||||
let mut stations = 0;
|
||||
for i in 1..12 {
|
||||
let phi = mid_azimuth + (i as f64 / 12.0 - 0.5) * 0.9;
|
||||
let (Some(p), Some(w)) = (at_azimuth(&plane, axis, phi), at_azimuth(&wall, axis, phi)) else { continue };
|
||||
stations += 1;
|
||||
worst = worst.max((p.dist(w) - chord).abs());
|
||||
}
|
||||
eprintln!("arc chord {chord}: worst width error {worst:.6} mm at {stations} stations");
|
||||
assert!(stations >= 9, "only {stations} stations measured");
|
||||
assert!(worst < 0.01, "the width across the blend is {worst} mm off the chord {chord}");
|
||||
}
|
||||
|
||||
/// A chord fillet on a straight edge between planar faces is still the exact local round: the
|
||||
/// same body, bit for bit, as the fillet of the radius the chord gives (a right angle: chord c is
|
||||
/// r = c / √2).
|
||||
#[test]
|
||||
fn chord_fillet_on_a_straight_edge_is_unchanged() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let (at, chord) = (Vec3::new(10.0, 10.0, 30.0), 4.0);
|
||||
let f = fillet_chord(&b, &[at], chord).unwrap();
|
||||
let k = fillet(&b, &[at], chord / 2f64.sqrt()).unwrap();
|
||||
assert_eq!(step_export(&[&f], "part").unwrap(), step_export(&[&k], "part").unwrap());
|
||||
assert_eq!(f.unmeshed_faces(), 0);
|
||||
}
|
||||
|
||||
/// A variable radius along an arc edge: a ball whose radius is the law's at each station's fraction
|
||||
/// of the edge's length rolls along it, and the setback on the planar face — the plane and the
|
||||
/// wall meet at a right angle, so it is the section's radius — follows the law from end to end.
|
||||
#[test]
|
||||
fn variable_radius_along_an_arc_edge_follows_the_law() {
|
||||
let (body, mid, axis, _) = arc_walled_box(false);
|
||||
let edge = crate::surfaces::edge_near(&body, mid).unwrap();
|
||||
// Started from the edge's back end: the law runs the other way, as the positions are from the
|
||||
// end nearest `start`.
|
||||
let start = vtx(edge.absolute_back());
|
||||
// A wider law (2 → 4, the radius doubling over the arc) is out of reach of the fitted strip:
|
||||
// the sections' own last spans cannot be held to the fit's accuracy there. That is refused,
|
||||
// not approximated.
|
||||
for wide in [4.0, 5.0] {
|
||||
let e = fillet_variable(&body, mid, start, 2.0, wide).unwrap_err();
|
||||
assert!(e.to_string().contains("accuracy and section limits"), "2 -> {wide}: {e}");
|
||||
}
|
||||
let (r0, r1) = (2.0, 3.4);
|
||||
let blended = fillet_variable(&body, mid, start, r0, r1).unwrap();
|
||||
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||
assert_eq!(blended.unmeshed_faces(), 0);
|
||||
let law = RadiusSpline::through(&[(0.0, r0), (1.0, r1)], false);
|
||||
// The law runs from the end nearest `start`: the chain's front here is the far end.
|
||||
let want = if vtx(edge.absolute_back()).dist(start) < vtx(edge.absolute_front()).dist(start) { law.reversed() } else { law.clone() };
|
||||
// `start` is the chain's back end, so the law is read back to front there: r0 at the back, r1
|
||||
// at the front.
|
||||
assert!((want.eval(1.0) - r0).abs() < 1e-12 && (want.eval(0.0) - r1).abs() < 1e-12);
|
||||
// The blend's contact on the bottom plane is at 40 − r from the axis.
|
||||
let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6);
|
||||
assert!(!plane.is_empty(), "no contact on the bottom plane");
|
||||
let mut worst: f64 = 0.0;
|
||||
let mut stations = 0;
|
||||
let mut shown = Vec::new();
|
||||
for e in &plane {
|
||||
let c = e.oriented_curve();
|
||||
let (lo, hi) = c.range_tuple();
|
||||
for i in 0..=32 {
|
||||
let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / 32.0));
|
||||
let measured = 40.0 - (p - axis).len();
|
||||
let along = along_fraction(&edge, p).unwrap();
|
||||
// Leave the two ends, where the caps cut the contact: the blend closes on the end
|
||||
// faces there.
|
||||
if !(0.04..=0.96).contains(&along) {
|
||||
continue;
|
||||
}
|
||||
stations += 1;
|
||||
if shown.len() < 3 || (0.45..0.55).contains(&along) {
|
||||
shown.push((along, measured, want.eval(along)));
|
||||
}
|
||||
worst = worst.max((measured - want.eval(along)).abs());
|
||||
}
|
||||
}
|
||||
eprintln!("arc variable {r0} -> {r1}: worst radius error {worst:.6} mm at {stations} stations; law vs measured {shown:?}");
|
||||
assert!(stations >= 24, "only {stations} stations measured");
|
||||
assert!(worst < 0.02, "the section radius is {worst} mm off the law");
|
||||
// Wider, still inside what the strip holds: the same body as the exact straight-edge case of
|
||||
// that law is not built here, but the radius is the law's at every station measured.
|
||||
}
|
||||
|
||||
/// A radius law that runs to zero at an end of a curved edge: refused by name. The blend would
|
||||
/// close to the edge's end point, but the fitted strip pinches to a point there and the last span
|
||||
/// of it cannot be held to the fit's accuracy: the straight-edge fillet, which closes exactly, is
|
||||
/// the way (see `variable_radius_down_to_zero_at_one_end`).
|
||||
#[test]
|
||||
fn a_law_running_to_zero_along_a_curved_edge_is_refused() {
|
||||
let (body, mid, _, _) = arc_walled_box(false);
|
||||
let edge = crate::surfaces::edge_near(&body, mid).unwrap();
|
||||
for start in [vtx(edge.absolute_front()), vtx(edge.absolute_back())] {
|
||||
let e = fillet_variable(&body, mid, start, 3.0, 0.0).unwrap_err();
|
||||
assert!(e.to_string().contains("runs to zero along a curved edge"), "{e}");
|
||||
let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![0.0, 3.0] };
|
||||
assert!(fillet_variable_spline(&body, mid, mid, &law).is_err());
|
||||
// A law that stays clear of zero along it rolls: the blend is closed, a three-sided face
|
||||
// where nothing closes it.
|
||||
let made = fillet_variable(&body, mid, start, 3.0, 2.4).unwrap();
|
||||
assert!(made.validity().is_empty(), "{:?}", made.validity());
|
||||
let mesh = made.mesh(&crate::MeshTolerance::chord(5e-4)).unwrap();
|
||||
let about = |o: Vec3| {
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.map(|t| (mesh.positions[t[0] as usize] - o).dot((mesh.positions[t[1] as usize] - o).cross(mesh.positions[t[2] as usize] - o)) / 6.0)
|
||||
.sum::<f64>()
|
||||
};
|
||||
assert!((about(Vec3::ZERO) - about(Vec3::new(-100.0, 50.0, 30.0))).abs() < 1e-2, "the mesh is not closed");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chord or a law the local geometry cannot hold is refused with: an error naming the size,
|
||||
/// never a silent approximation and never a panic.
|
||||
#[test]
|
||||
fn a_chord_or_radius_the_faces_cannot_hold_is_refused() {
|
||||
let (body, mid, _, _) = arc_walled_box(false);
|
||||
// A chord of 30 across a 40 mm wide wall (r ≈ 21, past the side walls and the block).
|
||||
let e = fillet_chord(&body, &[mid], 30.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
assert!(fillet_chord(&body, &[mid], 3.0 / 2f64.sqrt()).is_ok());
|
||||
// A radius law too large for the wall.
|
||||
assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 2.0, 30.0).is_err());
|
||||
assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 20.0, 20.0).is_err());
|
||||
// Non-finite, negative and zero-at-both-ends laws: refused before the geometry is touched.
|
||||
let bad = |radii: Vec<f64>| RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii };
|
||||
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::NAN])).is_err());
|
||||
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::INFINITY])).is_err());
|
||||
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, -1.0])).is_err());
|
||||
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![0.0, 0.0])).is_err());
|
||||
assert!(fillet_variable_spline(&body, mid, mid, &RadiusSpline { degree: 1, knots: vec![0.0, 0.5, 1.0], radii: vec![2.0, 3.0] }).is_err());
|
||||
assert!(fillet_variable_points(&body, mid, mid, &[(0.0, 2.0), (1.5, 3.0)], false).is_err());
|
||||
// An edge the body does not have is refused by name, not approximated.
|
||||
assert!(fillet_chord(&body, &[Vec3::new(500.0, 500.0, 500.0)], 3.0).is_err());
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
//! Independent analytic reference: the centre of a ball tangent to two perpendicular
|
||||
//! cylinders lies on the intersection of their radius-offset cylinders.
|
||||
|
||||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||||
|
||||
use super::*;
|
||||
use crate::blend::plane_normal;
|
||||
use crate::issue_851_tests::planned_volume;
|
||||
use crate::{Body, BoolOp, boolean, cylinder, fillet};
|
||||
|
||||
#[test]
|
||||
fn issue_1303_chamfer_open_rim_after_end_chamfer() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
|
||||
let rim = Region2 {
|
||||
outer: Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(31.0, -15.0), b: Vec2::new(31.0, -11.75) },
|
||||
Seg2::Line { a: Vec2::new(31.0, -11.75), b: Vec2::new(0.0, -11.75) },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 11.75, start: -std::f64::consts::FRAC_PI_2, sweep: -std::f64::consts::PI },
|
||||
Seg2::Line { a: Vec2::new(0.0, 11.75), b: Vec2::new(31.0, 11.75) },
|
||||
Seg2::Line { a: Vec2::new(31.0, 11.75), b: Vec2::new(31.0, 15.0) },
|
||||
Seg2::Line { a: Vec2::new(31.0, 15.0), b: Vec2::new(0.0, 15.0) },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 15.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
||||
Seg2::Line { a: Vec2::new(0.0, -15.0), b: Vec2::new(31.0, -15.0) },
|
||||
],
|
||||
},
|
||||
holes: vec![],
|
||||
};
|
||||
let mut body = crate::extrude(&Plane::XY, &[rim], 0.0, 13.0).unwrap().pop().unwrap();
|
||||
// Exact local end bevels, meeting at the four-face vertices (30, +/-11.75, 13).
|
||||
body = crate::blend_corner::chamfer(
|
||||
&body,
|
||||
&[Vec3::new(31.0, 13.375, 13.0), Vec3::new(31.0, -13.375, 13.0), Vec3::new(31.0, 11.75, 8.0), Vec3::new(31.0, -11.75, 8.0)],
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
let picks = [Vec3::new(-11.75, 0.0, 13.0), Vec3::new(15.0, 11.75, 13.0), Vec3::new(15.0, -11.75, 13.0)];
|
||||
let result = crate::chamfer(&body, &picks, 1.0).unwrap();
|
||||
assert!(result.validity().is_empty(), "{:?}", result.validity());
|
||||
assert_eq!(result.crossed_face_loops().unwrap(), 0);
|
||||
// At height 12 + u, the removed half-ring area is pi/2 * (2*r*u + u^2).
|
||||
// The straight strips end at min(31-u, 30+v), 0 <= v <= u. Their integral
|
||||
// over 0 <= u <= 1 is 30 + 1/6; this includes both split end sections.
|
||||
let expected = 30.0 + 1.0 / 6.0 + std::f64::consts::PI * (11.75 / 2.0 + 1.0 / 6.0);
|
||||
let volume = |b: &Body| b.mesh(&crate::MeshTolerance::chord(0.0001)).unwrap().measure().volume;
|
||||
let removed = volume(&body) - volume(&result);
|
||||
assert!((removed - expected).abs() < 0.02, "removed {removed}, expected {expected}");
|
||||
for p in [Vec3::new(30.5, 12.25, 12.5), Vec3::new(30.5, -12.25, 12.5)] {
|
||||
assert!(result.solid.vertex_iter().any(|v| from_p3(v.point()).dist(p) < 1e-6));
|
||||
}
|
||||
for selection in [vec![picks[0]], picks.into_iter().rev().collect()] {
|
||||
let other = crate::chamfer(&body, &selection, 1.0).unwrap();
|
||||
assert!(other.validity().is_empty(), "{:?}", other.validity());
|
||||
assert!((volume(&body) - volume(&other) - expected).abs() < 0.02);
|
||||
}
|
||||
assert!(crate::chamfer(&body, &picks, 4.0).is_err());
|
||||
}
|
||||
|
||||
fn tee(scale: f64, origin: Vec3) -> Body {
|
||||
let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
|
||||
let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
|
||||
boolean(&main, &branch, BoolOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) {
|
||||
let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt());
|
||||
let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0);
|
||||
let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z);
|
||||
(origin + centre * scale, origin + main * scale, origin + branch * scale)
|
||||
}
|
||||
|
||||
fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) {
|
||||
surfaces
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?;
|
||||
let actual = from_p3(s.subs(u, v));
|
||||
let n = s.normal(u, v);
|
||||
Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 {
|
||||
let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO);
|
||||
let a = (main - centre).normalized().unwrap();
|
||||
let b = (branch - centre).normalized().unwrap();
|
||||
let plane = a.cross(b).normalized().unwrap();
|
||||
let radial = point - centre;
|
||||
let projected = radial - plane * radial.dot(plane);
|
||||
let direction = projected.normalized().unwrap();
|
||||
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
|
||||
let from_main = a.dot(direction).clamp(-1.0, 1.0).acos();
|
||||
let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos();
|
||||
if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) }
|
||||
}
|
||||
|
||||
fn distance_to_analytic_band(point: Vec3) -> f64 {
|
||||
// The arc's XY azimuth stays between its two contacts' azimuths. Their difference
|
||||
// for this tee is less than 0.1 rad; bracket generously around the query azimuth.
|
||||
let azimuth = point.y.atan2(point.x);
|
||||
let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3);
|
||||
for _ in 0..55 {
|
||||
let a = lo + (hi - lo) * 0.38196601125;
|
||||
let b = lo + (hi - lo) * 0.61803398875;
|
||||
if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) {
|
||||
hi = b;
|
||||
} else {
|
||||
lo = a;
|
||||
}
|
||||
}
|
||||
distance_to_analytic_arc((lo + hi) * 0.5, point)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_contact_edges_coincide_with_their_surface_boundaries() {
|
||||
let sections: Vec<Vec<Section>> = (0..4)
|
||||
.map(|k| {
|
||||
(0..SAMPLES)
|
||||
.map(|i| {
|
||||
let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64);
|
||||
let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO);
|
||||
Section { c, q1, q2 }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
for k in 0..sections.len() {
|
||||
let fit = crate::guard("contact consistency", || fit_sections(§ions, k, true, true)).unwrap();
|
||||
for (side, curve) in fit.contacts.iter().enumerate() {
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
for i in 0..=200 {
|
||||
let parameter = lo + (hi - lo) * i as f64 / 200.0;
|
||||
let edge = from_p3(curve.subs(parameter));
|
||||
let surface = from_p3(fit.surface.subs(side as f64, parameter));
|
||||
assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_matches_analytic_tee_and_contacts() {
|
||||
for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] {
|
||||
let body = tee(scale, origin);
|
||||
let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale;
|
||||
let blend = fillet(&body, &[point], 3.0 * scale).unwrap();
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||||
assert!(!surfaces.is_empty());
|
||||
let mut distance: f64 = 0.0;
|
||||
let mut tangent_angle: f64 = 0.0;
|
||||
for i in 0..96 {
|
||||
// Offset the angular samples from patch seams; contact endpoints are tested below.
|
||||
let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0;
|
||||
let (centre, main, branch) = analytic_section(theta, scale, origin);
|
||||
let a = (main - centre).normalized().unwrap();
|
||||
let b = (branch - centre).normalized().unwrap();
|
||||
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
|
||||
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
// Spherical linear interpolation of the radial unit vectors, independent of
|
||||
// the production rational control-point construction and fitting parameters.
|
||||
let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin();
|
||||
let exact = centre + radial * (3.0 * scale);
|
||||
let (error, normal) = nearest_blend(&surfaces, exact);
|
||||
distance = distance.max(error);
|
||||
if fraction == 0.0 || fraction == 1.0 {
|
||||
let want = (centre - exact).normalized().unwrap();
|
||||
tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm");
|
||||
assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees");
|
||||
|
||||
// The reverse direction uses points on the actual trimmed face mesh, so an
|
||||
// untrimmed neighbouring surface cannot conceal an overshooting blend patch.
|
||||
let mesh = blend.mesh(&crate::MeshTolerance::chord(0.002)).unwrap();
|
||||
let mut reverse_distance: f64 = 0.0;
|
||||
let mut probes = 0;
|
||||
for point in mesh.positions.iter().step_by(19) {
|
||||
let p = (*point - origin) / scale;
|
||||
if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001
|
||||
|| (p.y * p.y + p.z * p.z).sqrt() <= 15.000001
|
||||
|| !(10.0..20.0).contains(&p.z)
|
||||
|| p.x.abs() > 16.0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
probes += 1;
|
||||
reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale);
|
||||
}
|
||||
assert!(probes > 300, "only {probes} trimmed surface points checked");
|
||||
assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm");
|
||||
eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees");
|
||||
|
||||
let mut contact_count = 0;
|
||||
for edge in Body::unique_edges(&blend.solid) {
|
||||
let curve = edge.oriented_curve();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale);
|
||||
if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) {
|
||||
continue;
|
||||
}
|
||||
let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5);
|
||||
let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5);
|
||||
if !branch && !main {
|
||||
continue;
|
||||
}
|
||||
contact_count += 1;
|
||||
for i in 0..=200 {
|
||||
let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale;
|
||||
let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() };
|
||||
assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale);
|
||||
}
|
||||
}
|
||||
assert!(contact_count >= 4, "only {contact_count} contacts checked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
|
||||
let body = tee(1.0, Vec3::ZERO);
|
||||
let solid = body.deep_copy();
|
||||
let ids: Vec<_> = Body::unique_edges(&solid)
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
let curve = edge.oriented_curve();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
let p = from_p3(curve.subs((lo + hi) * 0.5));
|
||||
p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01
|
||||
})
|
||||
.map(mt::Edge::id)
|
||||
.collect();
|
||||
assert!(!ids.is_empty());
|
||||
let before = crate::step_export(&[&body], "unchanged").unwrap();
|
||||
let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap();
|
||||
// Four sections deliberately undersample the saddle; production starts at 24.
|
||||
let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err();
|
||||
assert!(error.to_string().contains("accuracy and section limits"), "{error}");
|
||||
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
|
||||
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
|
||||
}
|
||||
|
||||
/// Plan-view construction of a round between two overlapping circles (a concave vertical edge
|
||||
/// where two Z cylinders meet): the ball centre `f`, the circle and fillet arc angles, and the
|
||||
/// area the round fills in, all independent of the kernel.
|
||||
struct Gap {
|
||||
/// Points along the round's arc in plan, contacts included.
|
||||
arc: Vec<Vec3>,
|
||||
/// Arc angles on circle 1, circle 2 and the fillet between the corner and the contacts.
|
||||
angles: [f64; 3],
|
||||
area: f64,
|
||||
}
|
||||
|
||||
fn gap(c1: Vec3, r1: f64, c2: Vec3, r2: f64, corner: Vec3, r: f64) -> Gap {
|
||||
// The ball centre is r from both circles, on the corner's side of the centre line.
|
||||
let (a, b) = (r1 + r, r2 + r);
|
||||
let d = c1.dist(c2);
|
||||
let u = (c2 - c1) * (1.0 / d);
|
||||
let along = (a * a - b * b + d * d) / (2.0 * d);
|
||||
let mut side = Vec3::new(-u.y, u.x, 0.0);
|
||||
if (corner - c1).dot(side) < 0.0 {
|
||||
side = side * -1.0;
|
||||
}
|
||||
let f = c1 + u * along + side * (a * a - along * along).sqrt();
|
||||
let (t1, t2) = (c1 + (f - c1) * (r1 / a), c2 + (f - c2) * (r2 / b));
|
||||
let angle = |c: Vec3, p: Vec3, q: Vec3| (p - c).normalized().unwrap().dot((q - c).normalized().unwrap()).clamp(-1.0, 1.0).acos();
|
||||
let angles = [angle(c1, t1, corner), angle(c2, corner, t2), angle(f, t2, t1)];
|
||||
// Shoelace over the three short arcs: circle 1 to the corner, circle 2 on, fillet back.
|
||||
let mut ring = Vec::new();
|
||||
for (c, p, q) in [(c1, t1, corner), (c2, corner, t2), (f, t2, t1)] {
|
||||
let (p0, q0) = (p - c, q - c);
|
||||
let (a0, a1) = (p0.y.atan2(p0.x), q0.y.atan2(q0.x));
|
||||
let mut sweep = a1 - a0;
|
||||
while sweep > std::f64::consts::PI {
|
||||
sweep -= std::f64::consts::TAU;
|
||||
}
|
||||
while sweep < -std::f64::consts::PI {
|
||||
sweep += std::f64::consts::TAU;
|
||||
}
|
||||
let rr = p0.len();
|
||||
for i in 0..20_000 {
|
||||
let t = a0 + sweep * i as f64 / 20_000.0;
|
||||
ring.push(c + Vec3::new(t.cos(), t.sin(), 0.0) * rr);
|
||||
}
|
||||
}
|
||||
let area = ring.iter().map(|p| solidcraft_geom::Vec2::new(p.x, p.y)).collect::<solidcraft_geom::Ring<_>>().signed_area().abs();
|
||||
let (p, q) = ((t1 - f).normalized().unwrap(), (t2 - f).normalized().unwrap());
|
||||
let arc = (0..=8).map(|i| angles[2] * i as f64 / 8.0).map(|t| f + (p * ((angles[2] - t).sin()) + q * t.sin()) * (r / angles[2].sin())).collect();
|
||||
Gap { arc, angles, area }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_along_a_straight_edge_between_cylinders_trims_curved_end_seams() {
|
||||
// Two Z cylinders crossing square to each other: the vertical lines where they meet are
|
||||
// concave edges between two curved faces, and the blend ends on the planar caps, where the
|
||||
// seams are arcs.
|
||||
let (r1, r2, h, r) = (17.0, 9.5, 10.0, 6.0);
|
||||
let (c1, c2) = (Vec3::ZERO, Vec3::new(r1, r2, 0.0));
|
||||
let a = cylinder(c1, Vec3::Z, r1, h).unwrap();
|
||||
let b = cylinder(c2, Vec3::Z, r2, h).unwrap();
|
||||
let body = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
// The corner: one of the two points where the circles cross.
|
||||
let d = c1.dist(c2);
|
||||
let u = (c2 - c1) * (1.0 / d);
|
||||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let corner = c1 + u * along + Vec3::new(-u.y, u.x, 0.0) * (r1 * r1 - along * along).sqrt();
|
||||
let g = gap(c1, r1, c2, r2, corner, r);
|
||||
let blend = fillet(&body, &[corner + Vec3::Z * (h / 2.0)], r).unwrap();
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
// One blend face; the edge is replaced by two contact lines and two end arcs.
|
||||
let (before, after) = (crate::measure(&body).unwrap(), crate::measure(&blend).unwrap());
|
||||
assert_eq!((after.faces, after.edges, after.vertices), (before.faces + 1, before.edges + 3, before.vertices + 2), "{before:?} -> {after:?}");
|
||||
// The round fills the gap over the full height. The caps gain it twice; the walls lose
|
||||
// their arcs to the contacts and gain the round.
|
||||
let (m0, m1) = (body.mesh(&crate::MeshTolerance::chord(0.0005)).unwrap().measure(), blend.mesh(&crate::MeshTolerance::chord(0.0005)).unwrap().measure());
|
||||
let volume = h * g.area;
|
||||
assert!((m1.volume - m0.volume - volume).abs() < volume * 3e-3, "added volume {} vs {volume}", m1.volume - m0.volume);
|
||||
let [a1, a2, af] = g.angles;
|
||||
let area = 2.0 * g.area + h * (r * af - r1 * a1 - r2 * a2);
|
||||
assert!((m1.area - m0.area - area).abs() < area.abs() * 3e-3, "added area {} vs {area}", m1.area - m0.area);
|
||||
// The round is the analytic arc at every height, from contact to contact.
|
||||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||||
let mut worst: f64 = 0.0;
|
||||
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
for p in &g.arc {
|
||||
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_curved_seam_is_not_supported() {
|
||||
// A closed seam (one edge starting and ending at the same vertex, as a full cap circle can
|
||||
// be in imported geometry) has no "between" two contacts, so trimming it is refused rather
|
||||
// than guessed.
|
||||
let body = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap();
|
||||
let arc = body.solid.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).expect("a cap arc");
|
||||
let circle = mt::Edge::new_unchecked(arc.front(), arc.front(), arc.curve());
|
||||
assert_eq!(circle.front(), circle.back());
|
||||
// Contacts on the circle itself: a third and two thirds of the way round.
|
||||
let (lo, hi) = circle.curve().range_tuple();
|
||||
let on = |f: f64| mt::Vertex::new(circle.curve().subs(lo + (hi - lo) * f));
|
||||
for (a, b) in [(Some(on(1.0 / 3.0)), None), (None, Some(on(2.0 / 3.0))), (Some(on(1.0 / 3.0)), Some(on(2.0 / 3.0))), (None, None)] {
|
||||
let err = trim_curved_seam(&circle, a, b, 20.0).unwrap_err();
|
||||
assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() {
|
||||
// The crossing cylinders of the test above with the small one's centre inside the large
|
||||
// one: a round of 3 would reach past the end of the cap arc it trims, so it is too large
|
||||
// (a smaller one fits), and the largest that fits is the analytic round over the full height
|
||||
// (checked on its surface: its added volume is too small beside the body's to measure).
|
||||
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
|
||||
let (c1, c2) = (Vec3::ZERO, Vec3::new(d, 0.0, 0.0));
|
||||
let body = boolean(&cylinder(c1, Vec3::Z, r1, h).unwrap(), &cylinder(c2, Vec3::Z, r2, h).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let corner = Vec3::new(along, (r1 * r1 - along * along).sqrt(), 0.0);
|
||||
let at = [corner + Vec3::Z * (h / 2.0)];
|
||||
let e = fillet(&body, &at, 3.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let (r, blend) = crate::fillet_max(&body, &at, 3.0).unwrap();
|
||||
assert!(r > 1.5 && r < 3.0, "{r}");
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
let g = gap(c1, r1, c2, r2, corner, r);
|
||||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||||
let mut worst: f64 = 0.0;
|
||||
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
for p in &g.arc {
|
||||
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() {
|
||||
// The crossing cylinders' seam refuses a round of 3 (too large). A bar joined to them has
|
||||
// two straight edges at its far end that round on their own. Each blend there leaves the
|
||||
// seam's surroundings as they were, so the seam is tried once, not once per blend, and the
|
||||
// refusal is the same.
|
||||
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
|
||||
let body = boolean(&cylinder(Vec3::ZERO, Vec3::Z, r1, h).unwrap(), &cylinder(Vec3::new(d, 0.0, 0.0), Vec3::Z, r2, h).unwrap(), BoolOp::Union)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let bar = crate::box_solid(Vec3::new(-80.0, -5.0, 0.0), Vec3::new(-10.0, 5.0, h)).unwrap();
|
||||
let body = boolean(&body, &bar, BoolOp::Union).unwrap().unwrap();
|
||||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let seam = Vec3::new(along, (r1 * r1 - along * along).sqrt(), h / 2.0);
|
||||
let ends = [Vec3::new(-80.0, -5.0, h / 2.0), Vec3::new(-80.0, 5.0, h / 2.0)];
|
||||
crate::blend::ROLLS.with(|n| n.set(0));
|
||||
let e = fillet(&body, &[seam, ends[0], ends[1]], 3.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let rolls = crate::blend::ROLLS.with(|n| n.get());
|
||||
assert_eq!(rolls, 1, "the seam was rolled {rolls} times");
|
||||
// The far edges alone still round.
|
||||
assert!(fillet(&body, &ends, 3.0).is_ok());
|
||||
}
|
||||
|
||||
/// At a knife edge (the plane and the wall's normals opposite) the ball's centre is the same
|
||||
/// whichever face is side 1: `r` inside the plane and `r` outside the wall's cylinder.
|
||||
#[test]
|
||||
fn knife_edge_ball_centre_does_not_depend_on_the_side_order() {
|
||||
const R: f64 = 21.0;
|
||||
let tip = Vec3::new(62.0 + 1.0 / 6.0, 0.0, 5.0);
|
||||
let slab = crate::box_solid(Vec3::new(0.0, -R, 0.0), Vec3::new(tip.x, 0.0, 10.0)).unwrap();
|
||||
let disc = cylinder(Vec3::new(tip.x, -R, 0.0), Vec3::Z, R, 10.0).unwrap();
|
||||
let body = boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap();
|
||||
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||||
let plane = faces.iter().copied().find(|f| plane_normal(f).is_some_and(|n| n.y > 0.99)).unwrap();
|
||||
let wall = faces.iter().copied().find(|f| plane_normal(f).is_none()).unwrap();
|
||||
for r in [0.5, 2.0, 5.0] {
|
||||
let centre = Vec3::new(tip.x - (4.0 * R * r).sqrt(), -r, tip.z);
|
||||
// `cusp` is +1 when n1 × t points into the cusp: the wall's normal is −y, the plane's +y.
|
||||
for (f1, f2, cusp) in [(wall, plane, 1.0), (plane, wall, -1.0)] {
|
||||
let s = solve(&[f1], &[f2], tip, Vec3::Z, r, -1.0, cusp, None, 1e-6).unwrap();
|
||||
assert!(s.c.dist(centre) < 1e-6, "r={r} cusp={cusp}: centre {:?}, expected {centre:?}", s.c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #896: the seams where rounds run into the faces beside them are picked to be rounded again.
|
||||
/// The faces meet tangentially there, so there is nothing to round: the refusal says so, rather
|
||||
/// than that blending such an edge isn't supported yet.
|
||||
#[test]
|
||||
fn a_smooth_seam_is_refused_as_having_no_corner() {
|
||||
let b = crate::box_solid(Vec3::ZERO, Vec3::new(150.0, 200.0, 70.0)).unwrap();
|
||||
let mut edges = Vec::new();
|
||||
for (x, y) in [(0.0, 0.0), (150.0, 0.0), (0.0, 200.0), (150.0, 200.0)] {
|
||||
edges.push(Vec3::new(x, y, 35.0));
|
||||
}
|
||||
for (a, z) in [(0.0, 0.0), (150.0, 0.0), (0.0, 70.0), (150.0, 70.0)] {
|
||||
edges.push(Vec3::new(a, 100.0, z));
|
||||
}
|
||||
for (y, z) in [(0.0, 0.0), (200.0, 0.0), (0.0, 70.0), (200.0, 70.0)] {
|
||||
edges.push(Vec3::new(75.0, y, z));
|
||||
}
|
||||
let rounded = fillet(&b, &edges, 10.0).unwrap();
|
||||
// The seam on the top face beside the round of the edge along y at x = 0, and the seam
|
||||
// with the end face x = 0 beside the round of the top edge along y.
|
||||
for seam in [Vec3::new(10.0, 100.0, 70.0), Vec3::new(0.0, 100.0, 60.0)] {
|
||||
for chamfer in [false, true] {
|
||||
let r = if chamfer { crate::chamfer(&rounded, &[seam], 1.0) } else { fillet(&rounded, &[seam], 1.0) };
|
||||
match r {
|
||||
Err(KernelError::Invalid(m)) => assert!(m.contains("smooth") && m.contains("no corner"), "{m}"),
|
||||
other => panic!("{seam:?}: {:?}", other.map(|_| "a body")),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A sharp edge of a box with one round still takes a fillet beside the round's seams.
|
||||
let one = fillet(&crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(), &[Vec3::new(40.0, 30.0, 10.0)], 5.0).unwrap();
|
||||
assert!(fillet(&one, &[Vec3::new(20.0, 0.0, 20.0)], 1.0).is_ok());
|
||||
assert!(matches!(fillet(&one, &[Vec3::new(35.0, 30.0, 10.0)], 1.0), Err(KernelError::Invalid(_))));
|
||||
}
|
||||
|
||||
/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a
|
||||
/// cylinder away from the front (the wall curves into the part), otherwise the part is the box
|
||||
/// within the cylinder (the wall curves out). The arc meets the planar side walls x = 0 and
|
||||
/// x = 40 at an angle, not square. With the bottom arc's midpoint, the axis's base point and the
|
||||
/// side of the wall the material is on (+1 away from the axis, −1 toward it).
|
||||
fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) {
|
||||
let block = crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let (axis, op, side) =
|
||||
if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) };
|
||||
let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap();
|
||||
let body = boolean(&block, &tool, op).unwrap().unwrap();
|
||||
(body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side)
|
||||
}
|
||||
|
||||
/// Volume the blend removes along the bottom arc: the corner's cross-section at each distance
|
||||
/// ρ from the axis (`depth(x)`, x ∈ [0, size] from the wall into the material), times the arc
|
||||
/// of radius ρ between the side walls.
|
||||
fn removed_volume(side: f64, size: f64, depth: impl Fn(f64) -> f64) -> f64 {
|
||||
let steps = 4000;
|
||||
let h = size / steps as f64;
|
||||
(0..=steps)
|
||||
.map(|i| {
|
||||
let x = i as f64 * h;
|
||||
let rho = 40.0 + side * x;
|
||||
let w = if i == 0 || i == steps {
|
||||
1.0
|
||||
} else if i % 2 == 1 {
|
||||
4.0
|
||||
} else {
|
||||
2.0
|
||||
};
|
||||
w * depth(x) * 2.0 * rho * (20.0 / rho).asin()
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_along_an_arc_ends_in_side_walls_at_an_angle() {
|
||||
// The bottom edge of an arc wall ends in the side walls at an angle (#556). The round is the
|
||||
// torus about the cylinder's axis (centre circle of radius 40 ± r at height r), trimmed by
|
||||
// the side walls; the chamfer is the cone between the two contacts.
|
||||
let r = 2.0;
|
||||
for concave in [true, false] {
|
||||
let (body, mid, axis, side) = arc_walled_box(concave);
|
||||
let v0 = planned_volume(&body);
|
||||
for round in [true, false] {
|
||||
let what = format!("{} {}", if concave { "concave" } else { "convex" }, if round { "fillet" } else { "chamfer" });
|
||||
let blend = if round { fillet(&body, &[mid], r) } else { crate::chamfer(&body, &[mid], r) }.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(blend.validity().is_empty(), "{what}: {:?}", blend.validity());
|
||||
let volume = planned_volume(&blend);
|
||||
let removed =
|
||||
if round { removed_volume(side, r, |x| r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) } else { removed_volume(side, r, |x| r - x) };
|
||||
assert!((v0 - volume - removed).abs() < removed * 3e-3, "{what}: removed {} vs {removed}", v0 - volume);
|
||||
// The blend face stays between the side walls and, for the round, on the torus.
|
||||
let surfaces: Vec<_> =
|
||||
blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||||
assert!(!surfaces.is_empty(), "{what}: no blend face");
|
||||
let mesh = blend.mesh(&crate::MeshTolerance::chord(0.0005)).unwrap();
|
||||
let (lo, hi) = mesh.positions.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
|
||||
assert!(lo > -1e-3 && hi < 40.0 + 1e-3, "{what}: the blend runs past the side walls: x {lo}..{hi}");
|
||||
if round {
|
||||
// Sections of the round across the arc: the quarter circle about the centre
|
||||
// ring (radius 40 ± r about the axis, at height r) from contact to contact.
|
||||
let ring = 40.0 + side * r;
|
||||
let mut worst: f64 = 0.0;
|
||||
for i in 0..=40 {
|
||||
let x = 40.0 * i as f64 / 40.0;
|
||||
for a in 1..8 {
|
||||
let t = std::f64::consts::FRAC_PI_2 * a as f64 / 8.0;
|
||||
let (rho, z) = (ring - side * r * t.cos(), r - r * t.sin());
|
||||
let dx = x - axis.x;
|
||||
let dy = (rho * rho - dx * dx).sqrt() * if concave { 1.0 } else { -1.0 };
|
||||
let p = Vec3::new(x, axis.y + dy, z);
|
||||
worst = worst.max(nearest_blend(&surfaces, p).0);
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-3, "{what}: blend surface is {worst} mm off the torus");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_past_a_slanted_end_face_is_too_large_and_reduces() {
|
||||
// On the outward-curving wall a round of 4 would run its bottom contact into the back face
|
||||
// before it reaches the side wall (the contact circle of radius 36 meets x = 0 at y ≈ 40.07):
|
||||
// too large, and the largest that fits rounds the edge.
|
||||
let (body, mid, _, _) = arc_walled_box(false);
|
||||
let e = fillet(&body, &[mid], 4.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let (r, blend) = crate::fillet_max(&body, &[mid], 4.0).unwrap();
|
||||
assert!(r > 3.0 && r < 4.0, "{r}");
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_runs_into_a_knife_edge_tip() {
|
||||
// The corner of a block outside a cylinder of radius 21 about (21, −21): its wall meets the
|
||||
// back face y = 0 tangentially at (21, 0) and the left face x = 0 at (0, −21), knife-edge
|
||||
// tips at both ends of the arc. The round of the bottom arc runs into both, cut off by the
|
||||
// end faces' planes, which touch the wall contact at the tips without crossing it: at
|
||||
// distance ρ from the axis it runs from x = 0 to y = 0, ρ (asin(21 / ρ) − acos(21 / ρ)).
|
||||
let block = crate::box_solid(Vec3::new(0.0, -21.0, 0.0), Vec3::new(21.0, 0.0, 10.0)).unwrap();
|
||||
let body = boolean(&block, &cylinder(Vec3::new(21.0, -21.0, -1.0), Vec3::Z, 21.0, 12.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
|
||||
let mid = Vec3::new(21.0 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2, 0.0);
|
||||
let r = 2.0;
|
||||
let blend = fillet(&body, &[mid], r).unwrap();
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
let (n, h) = (4000, r / 4000.0);
|
||||
let removed = (0..=n)
|
||||
.map(|i| {
|
||||
let x = i as f64 * h;
|
||||
let rho = 21.0 + x;
|
||||
let w = if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 1 {
|
||||
4.0
|
||||
} else {
|
||||
2.0
|
||||
};
|
||||
w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * rho * ((21.0 / rho).asin() - (21.0 / rho).acos())
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let got = planned_volume(&body) - planned_volume(&blend);
|
||||
assert!((got - removed).abs() < removed * 5e-3, "removed {got} vs {removed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_turns_about_a_rounded_corner_of_its_radius() {
|
||||
// The box with the inward-curving wall, its 60° corner at x = 0 rounded (r 2) first. The
|
||||
// bottom edges then run: the side wall's, the corner round's (an arc of radius 2) and the
|
||||
// arc wall's into the slanted side wall at x = 40. Rounded again with r 2 the ball can't
|
||||
// roll round the corner: it turns about one point there, and the blend is a piece of sphere.
|
||||
// It is what the rolling round tends to as its radius tends to the corner's, too large past
|
||||
// it, and its mesh is closed.
|
||||
let (body, _, _, _) = arc_walled_box(true);
|
||||
let body = fillet(&body, &[Vec3::new(0.0, 4.641016151377546, 10.0)], 2.0).unwrap();
|
||||
// The corner round's centre is 4 from the corner along its bisector, (2, 8.105): its arc's
|
||||
// middle is 2 back toward the corner.
|
||||
let ends = [Vec3::new(0.0, 25.0, 0.0), Vec3::new(1.0, 6.373, 0.0), Vec3::new(20.0, 10.0, 0.0)];
|
||||
let v0 = planned_volume(&body);
|
||||
let removed = |r: f64| {
|
||||
let b = fillet(&body, &ends, r).unwrap_or_else(|e| panic!("r {r}: {e}"));
|
||||
assert!(b.validity().is_empty(), "r {r}: {:?}", b.validity());
|
||||
v0 - planned_volume(&b)
|
||||
};
|
||||
let (a, b, at) = (removed(1.96), removed(1.98), removed(2.0));
|
||||
let expected = 2.0 * b - a;
|
||||
assert!((at - expected).abs() < expected * 2e-3, "removed {at} at r 2, {a} and {b} before it");
|
||||
let e = fillet(&body, &ends, 2.5).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let (r, _) = crate::fillet_max(&body, &ends, 2.5).unwrap();
|
||||
assert!(r > 1.5 && r <= 2.0 + 1e-9, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_along_a_straight_edge_ends_in_a_curved_face() {
|
||||
// The box with the inward-curving wall: the bottom edge of the side wall x = 0 runs from the
|
||||
// back to the curved wall (#556). The round of radius 2 along it is cut off by that wall:
|
||||
// at d from the side wall it runs from y = −30 + √(40² − (d − 20)²) to the back at y = 40.
|
||||
let (body, _, _, _) = arc_walled_box(true);
|
||||
let r = 2.0;
|
||||
for round in [true, false] {
|
||||
let blend =
|
||||
if round { fillet(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) } else { crate::chamfer(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) }.unwrap();
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
let (n, h) = (4000, r / 4000.0);
|
||||
let removed = (0..=n)
|
||||
.map(|i| {
|
||||
let d = i as f64 * h;
|
||||
let w = if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 1 {
|
||||
4.0
|
||||
} else {
|
||||
2.0
|
||||
};
|
||||
let depth = if round { r - (r * r - (r - d) * (r - d)).max(0.0).sqrt() } else { r - d };
|
||||
w * depth * (40.0 - (-30.0 + (1600.0 - (d - 20.0) * (d - 20.0)).sqrt()))
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let (m0, m1) = (body.mesh(&crate::MeshTolerance::chord(0.0005)).unwrap(), blend.mesh(&crate::MeshTolerance::chord(0.0005)).unwrap());
|
||||
let got = m0.measure().volume - m1.measure().volume;
|
||||
assert!((got - removed).abs() < removed * 3e-3, "round {round}: removed {got} vs {removed}");
|
||||
let volume_about = |o: Vec3| {
|
||||
let at = |i: u32| m1.positions[i as usize] - o;
|
||||
m1.triangles.iter().map(|t| at(t[0]).dot(at(t[1]).cross(at(t[2]))) / 6.0).sum::<f64>()
|
||||
};
|
||||
let shift = volume_about(Vec3::ZERO) - volume_about(Vec3::new(-100.0, 50.0, 30.0));
|
||||
assert!(shift.abs() < 1e-2, "round {round}: the mesh is not closed (volume moves {shift} with the origin)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_a_chain_of_a_line_and_an_arc_into_a_slanted_end() {
|
||||
// A 40 × 40 block, 20 high, whose front runs straight for 20 and then along an arc of
|
||||
// radius 29 about (20, 29), tangent to it, up to (40, 8): the right face meets the arc at an
|
||||
// angle. The top front chain chamfered by 2 (the planes' and the cone's sections are 2 × 2
|
||||
// corners, 2 − w high at w in from the edge): 2 a millimetre along the line, and along the
|
||||
// arc out to the right face, at each distance ρ from its axis, the height times the arc of
|
||||
// radius ρ inside x ≤ 40.
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
|
||||
let start = -std::f64::consts::FRAC_PI_2;
|
||||
let end = (-21.0f64).atan2(20.0);
|
||||
let segs = vec![
|
||||
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(20.0, 0.0) },
|
||||
Seg2::Arc { center: Vec2::new(20.0, 29.0), radius: 29.0, start, sweep: end - start },
|
||||
Seg2::Line { a: Vec2::new(40.0, 8.0), b: Vec2::new(40.0, 40.0) },
|
||||
Seg2::Line { a: Vec2::new(40.0, 40.0), b: Vec2::new(0.0, 40.0) },
|
||||
Seg2::Line { a: Vec2::new(0.0, 40.0), b: Vec2::new(0.0, 0.0) },
|
||||
];
|
||||
let body = crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let volume = |b: &Body| b.mesh(&crate::MeshTolerance::chord(0.0005)).unwrap().measure().volume;
|
||||
let made = crate::chamfer(&body, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
|
||||
let n = 2000;
|
||||
let h = 2.0 / n as f64;
|
||||
let arc = (0..=n)
|
||||
.map(|i| {
|
||||
let rho = 27.0 + i as f64 * h;
|
||||
let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] };
|
||||
w * (rho - 27.0) * rho * (20.0 / rho).asin()
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let want = 40.0 + arc;
|
||||
let got = volume(&body) - volume(&made);
|
||||
assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}");
|
||||
}
|
||||
|
||||
/// #1667: an R1 round around the end of a long bar whose four long edges are R5. The chain is
|
||||
/// 290 mm lines and quarter turns of 7.85 mm, so the lines' sections are spaced far wider than
|
||||
/// the turns'; borrowing the turns' sections for the lines' ends made their strips overshoot and
|
||||
/// fold back past the joints, the strips' meshes folded with them, and the measured volume came
|
||||
/// out thousands of mm³ off. The R1 pipe is exact: the corner's section, `1 - π/4`, along the
|
||||
/// lines, and round each turn's axis at the section's centroid (Pappus).
|
||||
#[test]
|
||||
fn round_along_long_lines_and_small_turns_measures_its_volume() {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
|
||||
let (w, d, h) = (300.0, 60.0, 40.0);
|
||||
let bar = crate::build::box_solid(Vec3::new(-w / 2.0, 0.0, 0.0), Vec3::new(w / 2.0, d, h)).unwrap();
|
||||
let long = [Vec3::new(-w / 2.0, d / 2.0, 0.0), Vec3::new(w / 2.0, d / 2.0, 0.0), Vec3::new(-w / 2.0, d / 2.0, h), Vec3::new(w / 2.0, d / 2.0, h)];
|
||||
let rounded = fillet(&bar, &long, 5.0).unwrap();
|
||||
// The rolling ball along the end face's loop (the exact blends would take this loop; a
|
||||
// loop next to free-form faces, as in the issue, comes here).
|
||||
let ids: Vec<mt::EdgeID> = crate::body::Body::unique_edges(&rounded.solid)
|
||||
.into_iter()
|
||||
.filter(|e| [e.front(), e.back()].iter().all(|v| crate::blend::vtx(v).y.abs() < 1e-9))
|
||||
.map(|e| e.id())
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 8);
|
||||
let (solid, _) = crate::guard("test", || curve_blend(&rounded.solid, &ids, rounded.size(), 1.0, true)).unwrap();
|
||||
let body = Body::new(solid).unwrap();
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let section = 1.0 - FRAC_PI_4;
|
||||
// The section's centroid lies this far from its corner along each side.
|
||||
let inset = (5.0 / 6.0 - FRAC_PI_4) / section;
|
||||
let lines = 2.0 * (w - 10.0) + 2.0 * (h - 10.0);
|
||||
let turns = 4.0 * FRAC_PI_2 * (5.0 - inset);
|
||||
let want = w * d * h - 4.0 * 25.0 * section * d - section * (lines + turns);
|
||||
let m = crate::measure(&body).unwrap();
|
||||
assert!((m.volume - want).abs() < 1e-5 * want, "{} vs {want}", m.volume);
|
||||
// Centred across the bar, as the part is.
|
||||
assert!(m.centroid.x.abs() < 1e-3, "centre of mass x {}", m.centroid.x);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -275,9 +275,9 @@ fn rounds_together_in_any_order() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The blends this part needs the corner build for (and the back edge, rolled into the tip) at
|
||||
/// The blends this part has corners in (and the back edge, rounded into the tip) at
|
||||
/// 0.01 and 100 times the part's size, the radius scaled with it: they take off what they take at
|
||||
/// full size, scaled by the cube, so the build's tolerances and limits follow the model's size.
|
||||
/// full size, scaled by the cube, so the tolerances and limits follow the model's size.
|
||||
#[test]
|
||||
fn corner_blends_hold_at_any_scale() {
|
||||
let cases: [(&str, Vec<Vec<Vec3>>); 6] = [
|
||||
|
||||
@@ -1,959 +0,0 @@
|
||||
//! 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. Lines and arcs can meet tangentially or at mitred sharp
|
||||
//! corners. 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 curves at the joints. Sharp joints at perpendicular
|
||||
//! curved walls follow the intersections of the exact offsets, fitted within tolerance.
|
||||
//! A chamfer may also set back by different distances on the face and up the walls (two
|
||||
//! distances, or a distance and an angle: [`loop_chamfer_sides`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::blend::{ChamferSide, 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersection of two inward offsets nearest their shared corner, in the face plane.
|
||||
fn offset_joint(prev: &Seg, cur: &Seg, n: Vec3, d: f64) -> Option<Vec3> {
|
||||
let p = cur.start();
|
||||
let line_circle = |line: &Seg, arc: &Seg| {
|
||||
let Seg::Arc { a, c, .. } = *arc else { return None };
|
||||
let tangent = line.tangent(p)?;
|
||||
let origin = p + n.cross(tangent) * d;
|
||||
let radius = a.dist(c) + n.cross(arc.tangent(p)?).dot((p - c).normalized()?) * d;
|
||||
let foot = origin + tangent * (c - origin).dot(tangent);
|
||||
let height2 = radius * radius - foot.dist(c).powi(2);
|
||||
if !(radius > 0.0 && height2 >= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (foot + tangent * height2.sqrt(), foot - tangent * height2.sqrt());
|
||||
Some(if a.dist(p) < b.dist(p) { a } else { b })
|
||||
};
|
||||
match (prev, cur) {
|
||||
(Seg::Line { .. }, Seg::Arc { .. }) => line_circle(prev, cur),
|
||||
(Seg::Arc { .. }, Seg::Line { .. }) => line_circle(cur, prev),
|
||||
(Seg::Arc { a: a0, c: c0, .. }, Seg::Arc { a: a1, c: c1, .. }) => {
|
||||
let delta = *c1 - *c0;
|
||||
let distance = delta.len();
|
||||
let axis = delta.normalized()?;
|
||||
let r0 = a0.dist(*c0) + n.cross(prev.tangent(p)?).dot((p - *c0).normalized()?) * d;
|
||||
let r1 = a1.dist(*c1) + n.cross(cur.tangent(p)?).dot((p - *c1).normalized()?) * d;
|
||||
let along = (r0 * r0 - r1 * r1 + distance * distance) / (2.0 * distance);
|
||||
let height2 = r0 * r0 - along * along;
|
||||
if !(r0 > 0.0 && r1 > 0.0 && height2 >= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let foot = *c0 + axis * along;
|
||||
let across = n.cross(axis) * height2.sqrt();
|
||||
let (a, b) = (foot + across, foot - across);
|
||||
Some(if a.dist(p) < b.dist(p) { a } else { b })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A curved-wall joint is generally not planar. Fit its exact offset slices rather than
|
||||
/// projecting a circular section onto a tangent mitre and revolving that distorted section.
|
||||
/// `(r, rw)`: the round's radius (`rw` unused), or a chamfer's setbacks on the face and up the wall.
|
||||
fn offset_profile(prev: &Seg, cur: &Seg, n: Vec3, side: f64, (r, rw): (f64, f64), round: bool, tol: f64) -> Result<mt::Curve> {
|
||||
use mt::ParametricCurve;
|
||||
let point = |u: f64| {
|
||||
let (d, h) = if round {
|
||||
let (sin, cos) = (u * std::f64::consts::FRAC_PI_2).sin_cos();
|
||||
(r * (1.0 - sin), r * (1.0 - cos))
|
||||
} else {
|
||||
(r * (1.0 - u), rw * u)
|
||||
};
|
||||
offset_joint(prev, cur, n, d).map(|p| p + n * (side * h))
|
||||
};
|
||||
for count in [8, 16, 32, 64, 128] {
|
||||
let pts = (0..=count)
|
||||
.map(|i| point(f64::from(i) / f64::from(count)))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or_else(|| too_large("the blend's offset walls do not meet"))?;
|
||||
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| unsupported("curved corner profile"))?;
|
||||
if (0..count).all(|i| {
|
||||
let u = (f64::from(i) + 0.5) / f64::from(count);
|
||||
point(u).is_some_and(|p| p.dist(from_p3(curve.subs(u))) <= tol)
|
||||
}) {
|
||||
return Ok(mt::Curve::BSplineCurve(curve));
|
||||
}
|
||||
}
|
||||
Err(unsupported("could not fit the curved corner within tolerance"))
|
||||
}
|
||||
|
||||
/// 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 `edges` (by id) if they form a boundary loop or neighbouring run 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)>> {
|
||||
loop_blend_sized(solid, edge_ids, size, Sizing::Equal { r, round })
|
||||
}
|
||||
|
||||
/// [`loop_blend`] as a chamfer of `d1` on its first face and the second side by `side`. The
|
||||
/// first face is the one facing up most (the planar face or its wall, decided as for a straight
|
||||
/// edge, see [`crate::chamfer_sides`]), the other one with `flip`.
|
||||
pub(crate) fn loop_chamfer_sides(
|
||||
solid: &Solid,
|
||||
edge_ids: &[mt::EdgeID],
|
||||
size: f64,
|
||||
d1: f64,
|
||||
side: ChamferSide,
|
||||
flip: bool,
|
||||
) -> Option<Result<(Solid, Rebuild)>> {
|
||||
loop_blend_sized(solid, edge_ids, size, Sizing::Sides { d1, side, flip })
|
||||
}
|
||||
|
||||
/// How large a loop blend is.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Sizing {
|
||||
/// A round of radius `r` (`round`), or an equal-distance chamfer of `r`.
|
||||
Equal { r: f64, round: bool },
|
||||
/// A chamfer of `d1` on its first face, the second side by `side` ([`loop_chamfer_sides`]).
|
||||
Sides { d1: f64, side: ChamferSide, flip: bool },
|
||||
}
|
||||
|
||||
fn loop_blend_sized(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, sizing: Sizing) -> Option<Result<(Solid, Rebuild)>> {
|
||||
match crate::guard("loop blend", || Ok(loop_blend_inner(solid, edge_ids, size, sizing))) {
|
||||
Ok(result) => result,
|
||||
Err(error) => Some(Err(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, sizing: Sizing) -> Option<Result<(Solid, Rebuild)>> {
|
||||
#[cfg(test)]
|
||||
assert!(crate::guarded(), "loop selection must run inside the kernel guard");
|
||||
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, sizing, 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, sizing, 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,
|
||||
sizing: Sizing,
|
||||
run: Option<(usize, usize)>,
|
||||
) -> Result<(Solid, Rebuild)> {
|
||||
#[cfg(test)]
|
||||
assert!(crate::guarded(), "loop construction must run inside the kernel guard");
|
||||
// `r`: the round's radius or the equal chamfer's size; an unequal chamfer's first distance
|
||||
// (its setbacks come from `frame`).
|
||||
let (r, round, sides) = match sizing {
|
||||
Sizing::Equal { r, round } => (r, round, None),
|
||||
Sizing::Sides { d1, side, flip } => (d1, false, Some((side, flip))),
|
||||
};
|
||||
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 where the walls meet in one straight seam (the blends
|
||||
// then meet in a mitre). A loop can therefore mix lines and arcs at its corners.
|
||||
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 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 })
|
||||
};
|
||||
// An unequal chamfer: whether its first side is the planar face (else the walls). As for a
|
||||
// straight edge, that is the one facing up most (a curved wall by its mean normal along each
|
||||
// edge), the other with `flip`; it must be the same all round the loop.
|
||||
let face_first = match sides {
|
||||
None => true,
|
||||
Some((_, flip)) => {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let up = Vec3::new(1e-6, 1e-3, 1.0);
|
||||
let face_up = n.dot(up);
|
||||
let mut first: Option<bool> = None;
|
||||
for (pi, oi) in owner.iter().enumerate() {
|
||||
if pi > 0 && owner.get(pi - 1) == Some(oi) {
|
||||
continue;
|
||||
}
|
||||
let (Some(e), Some(g)) = (edges.get(*oi), walls.get(pi).and_then(|w| faces.get(*w))) else {
|
||||
return Err(KernelError::Failed("loop edge".into()));
|
||||
};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let ups: Vec<f64> =
|
||||
(0..=8).filter_map(|j| face_normal(g, from_p3(c.subs(t0 + (t1 - t0) * f64::from(j) / 8.0))).map(|gn| gn.dot(up))).collect();
|
||||
if ups.is_empty() {
|
||||
return Err(unsupported("wall normal"));
|
||||
}
|
||||
let wall_up = ups.iter().sum::<f64>() / ups.len() as f64;
|
||||
let mine = (face_up >= wall_up) != flip;
|
||||
if first.is_some_and(|x| x != mine) {
|
||||
return Err(unsupported(
|
||||
"an unequal chamfer round a loop whose first face changes along it (the face faces up more than some walls and less than others)",
|
||||
));
|
||||
}
|
||||
first = Some(mine);
|
||||
}
|
||||
first.unwrap_or(true)
|
||||
}
|
||||
};
|
||||
// Into the face (m), up the wall (t), the setbacks along each and the distance from the edge
|
||||
// to a round's centre.
|
||||
let frame = |i: usize, p: Vec3| -> Option<(Vec3, Vec3, f64, 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 (sf, sw) = match sides {
|
||||
None => {
|
||||
let sb = if round { r / (phi / 2.0).tan() } else { r };
|
||||
(sb, sb)
|
||||
}
|
||||
Some((cs, _)) => {
|
||||
let d2 = match cs {
|
||||
ChamferSide::Distance(d) => d,
|
||||
// The triangle edge–A–B: angle φ at the edge, `a` at A (on the first side).
|
||||
ChamferSide::Angle(a) => {
|
||||
let den = (phi + a).sin();
|
||||
if den <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
r * a.sin() / den
|
||||
}
|
||||
};
|
||||
if face_first { (r, d2) } else { (d2, r) }
|
||||
}
|
||||
};
|
||||
Some((m, t, sf, sw, r / (phi / 2.0).sin()))
|
||||
};
|
||||
if let Some((ChamferSide::Angle(a), _)) = sides {
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let p = s.start();
|
||||
if let Some(t) = up(i, p)
|
||||
&& (into(s, p).dot(t).clamp(-1.0, 1.0).acos() + a).sin() <= 1e-9
|
||||
{
|
||||
return Err(KernelError::Invalid("that chamfer angle runs past the other face".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
let mut curved_joint = vec![false; 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, sbw, 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 * sbw);
|
||||
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, sbw0, _) = 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();
|
||||
let exact = (matches!(segs.get(ip), Some(Seg::Arc { .. })) || matches!(segs.get(i), Some(Seg::Arc { .. })))
|
||||
&& t.dot(n * side) > 1.0 - 1e-9
|
||||
&& t0.dot(n * side) > 1.0 - 1e-9;
|
||||
if exact {
|
||||
let (Some(prev), Some(cur)) = (segs.get(ip), segs.get(i)) else { return Err(unsupported("corner segments")) };
|
||||
pa.push(
|
||||
offset_joint(prev, cur, n, if sides.is_some() { sb } else { r }).ok_or_else(|| too_large("the blend's offset walls do not meet"))?,
|
||||
);
|
||||
if let Some(slot) = curved_joint.get_mut(i) {
|
||||
*slot = true;
|
||||
}
|
||||
} else {
|
||||
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 || (sbw0 / h0 - sbw / h1).abs() > tol * 10.0 {
|
||||
return Err(unsupported("a sharp corner whose walls meet the blend differently"));
|
||||
}
|
||||
pb.push(p + e * (sbw / 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;
|
||||
// An unequal chamfer's offset on the face; it never ends in a point (a cone's apex).
|
||||
if sides.is_some() {
|
||||
let reach = frame(i, a).map(|x| x.2).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||||
if toward && a.dist(c) <= reach + tol * 10.0 {
|
||||
return Err(too_large("the chamfer is larger than a corner radius"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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 curved_joint.get(i).copied().unwrap_or(false) {
|
||||
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { return Err(unsupported("corner segments")) };
|
||||
let setbacks = match sides {
|
||||
None => (r, r),
|
||||
Some(_) => joint_point(i).and_then(|p| frame(i.min(k - 1), p)).map(|x| (x.2, x.3)).ok_or_else(|| unsupported("frame"))?,
|
||||
};
|
||||
let curve = offset_profile(prev, cur, n, side, setbacks, round, (setbacks.0.min(setbacks.1) * 1e-7).max(1e-9))?;
|
||||
profiles.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("curved corner: {e}")))?);
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
// q projects onto the wall rail at pb_i; fitting through pb_i before projection
|
||||
// would distort the circular cross-section at mixed line/arc corners.
|
||||
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, _, sbw, _)| p + t * sbw).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, sbw, _) = 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 * sbw + 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, .. }
|
||||
if !curved_joint.get(i).copied().unwrap_or(false) && !curved_joint.get(nxt(i)).copied().unwrap_or(false) =>
|
||||
{
|
||||
builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle))
|
||||
}
|
||||
Seg::Arc { a, c, axis, angle, .. } => {
|
||||
// Sweep a radial section, not the shared corner curve: the latter can be
|
||||
// nonplanar and would change the round's radius all along this edge.
|
||||
let (_, t, sb, sbw, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||||
let (fa, wa) = (builder::vertex(p3(a + into(s, a) * sb)), builder::vertex(p3(a + t * sbw)));
|
||||
let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i))) } else { builder::line(&fa, &wa) };
|
||||
builder::rsweep(&base, 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.
|
||||
// An unequal chamfer's profile has no centre: its middle, away from the edge on a concave
|
||||
// loop (where the chamfer fills) and toward it on a convex one.
|
||||
let (c, mid) = (centre(i), profile_mid(i));
|
||||
let (mid, outward) = match (sides, joint_point(i)) {
|
||||
(Some(_), Some(p)) => {
|
||||
let m = (from_p3(pr.front().point()) + from_p3(pr.back().point())) * 0.5;
|
||||
(m, (m - p) * side)
|
||||
}
|
||||
_ => (mid, (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))
|
||||
}
|
||||
Reference in New Issue
Block a user