Fillets and chamfers along closed chains of curved edges between any two smooth sides (a branch pipe meeting a main pipe): a ball rolled along the edge gives the contact curves and the blend (rational B-spline through exact arcs, smooth across the chain's joints); side faces trimmed to the contacts and seams shortened; merged topology joins free-form faces that meet smoothly by their exact normals
Oracle: 72/76. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
4cba923bec
commit
3e7826fe56
6 files changed
+507
-6
No files matched your search
+5
-3
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|---|---|
|
||||
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **70 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
|
||||
| Fusion oracle | **72 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
|
||||
@@ -60,8 +60,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
convex planar body or of an extruded part; fillets inside patterns. Edges between two curved
|
||||
faces are not supported yet.
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs). Open chains of curved edges are not supported
|
||||
yet.
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
|
||||
@@ -339,12 +339,55 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
}
|
||||
let solid = cur.deep_copy();
|
||||
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
|
||||
let out = guard(what, || blend_one(&solid, &edge, size, r, shape))?;
|
||||
let out = match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
|
||||
Ok(o) => o,
|
||||
// A curved edge: roll a ball along its smooth closed chain.
|
||||
Err(e) => match smooth_chain(&solid, &edge) {
|
||||
Some(ids) => guard(what, || crate::curveblend::curve_blend(&solid, &ids, size, r, shape == Shape::Round))
|
||||
.map_err(|e2| KernelError::Failed(format!("{e2} ({e})")))?,
|
||||
None => return Err(e),
|
||||
},
|
||||
};
|
||||
cur = Body::new(out)?;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// The closed chain of edges continuing `edge` smoothly at both ends (its ids), if any.
|
||||
fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let all = Body::unique_edges(solid);
|
||||
let dir = |e: &mt::Edge, at_end: bool| -> Option<Vec3> {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let d = c.der(if at_end { t1 } else { t0 });
|
||||
Vec3::new(d.x, d.y, d.z).normalized()
|
||||
};
|
||||
let mut ids = vec![edge.id()];
|
||||
let start = edge.front().clone();
|
||||
let (mut v, mut d) = (edge.back().clone(), dir(edge, true)?);
|
||||
for _ in 0..200 {
|
||||
if v == start {
|
||||
return Some(ids);
|
||||
}
|
||||
let next = all.iter().filter(|e| !ids.contains(&e.id()) && (*e.front() == v || *e.back() == v)).find_map(|e| {
|
||||
let fwd = *e.front() == v;
|
||||
let t = if fwd { dir(e, false)? } else { -dir(e, true)? };
|
||||
(t.dot(d) > 1.0 - 1e-4).then(|| (e.clone(), fwd))
|
||||
});
|
||||
let (e, fwd) = next?;
|
||||
ids.push(e.id());
|
||||
if fwd {
|
||||
v = e.back().clone();
|
||||
d = dir(&e, true)?;
|
||||
} else {
|
||||
v = e.front().clone();
|
||||
d = -dir(&e, false)?;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Rolling-ball blends along a closed chain of smooth edges between two smooth groups of faces
|
||||
//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball of the
|
||||
//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its
|
||||
//! contacts the curves where the blend meets each side. The blend face is a rational B-spline
|
||||
//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed
|
||||
//! back to the contact curves, and seams that met the edge are shortened to the contacts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Solid, from_p3, p3};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
fn unsupported(msg: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {msg}"))
|
||||
}
|
||||
|
||||
/// Samples per edge for the contact curves and the blend surface.
|
||||
const SAMPLES: usize = 24;
|
||||
|
||||
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Section {
|
||||
c: Vec3,
|
||||
q1: Vec3,
|
||||
q2: Vec3,
|
||||
}
|
||||
|
||||
/// Nearest point of a face's surface and the outward normal there.
|
||||
fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec3, (f64, f64))> {
|
||||
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||||
let s = f.oriented_surface();
|
||||
let uv = s.search_nearest_parameter(p3(p), hint, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?;
|
||||
let q = from_p3(s.subs(uv.0, uv.1));
|
||||
let n = s.normal(uv.0, uv.1);
|
||||
Some((q, Vec3::new(n.x, n.y, n.z).normalized()?, uv))
|
||||
}
|
||||
|
||||
/// Solve for the ball touching both sides near `p`. `extra` adds a third condition (a plane the
|
||||
/// side-k contact must lie in); without it the centre stays in the plane through `p` square to
|
||||
/// `t`. `sigma` is +1 when the ball sits outside the material (concave edge), −1 inside.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64) -> Option<Section> {
|
||||
// The nearest face of a side (several faces on one smooth surface group).
|
||||
let near = |fs: &[&mt::Face], c: Vec3| -> Option<(Vec3, Vec3)> {
|
||||
fs.iter().filter_map(|f| project(f, c, None).map(|(q, n, _)| (q, n))).min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c)))
|
||||
};
|
||||
let (q1, n1) = near(f1, p)?;
|
||||
let (q2, n2) = near(f2, p)?;
|
||||
let _ = (q1, q2);
|
||||
let den = 1.0 + n1.dot(n2);
|
||||
if den.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let mut c = p + (n1 + n2) * (sigma * r / den);
|
||||
for _ in 0..60 {
|
||||
let (q1, n1) = near(f1, c)?;
|
||||
let (q2, n2) = near(f2, c)?;
|
||||
let g1 = (c - q1).dot(n1) - sigma * r;
|
||||
let g2 = (c - q2).dot(n2) - sigma * r;
|
||||
// Third row: stay in the section plane, or put a contact on the seam plane.
|
||||
let (row3, g3) = match extra {
|
||||
None => (t, (c - p).dot(t)),
|
||||
Some((k, o, m)) => {
|
||||
let q = if k == 1 { q1 } else { q2 };
|
||||
(m, (q - o).dot(m))
|
||||
}
|
||||
};
|
||||
let a = [[n1.x, n1.y, n1.z], [n2.x, n2.y, n2.z], [row3.x, row3.y, row3.z]];
|
||||
let b = [-g1, -g2, -g3];
|
||||
let x = crate::polyhedron::solve3_pub(a, b)?;
|
||||
let dc = Vec3::new(x[0], x[1], x[2]);
|
||||
c += dc;
|
||||
if dc.len() < tol * 1e-3 && g1.abs() < tol && g2.abs() < tol && g3.abs() < tol {
|
||||
let (q1, _) = near(f1, c)?;
|
||||
let (q2, _) = near(f2, c)?;
|
||||
return Some(Section { c, q1, q2 });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Homogeneous control points of the circular (or straight) cross-section from q1 to q2.
|
||||
fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
|
||||
let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w);
|
||||
if !round {
|
||||
let m = (s.q1 + s.q2) * 0.5;
|
||||
return Some([h(s.q1, 1.0), h(m, 1.0), h(s.q2, 1.0)]);
|
||||
}
|
||||
let (a, b) = (s.q1 - s.c, s.q2 - s.c);
|
||||
let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos();
|
||||
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
|
||||
return None;
|
||||
}
|
||||
let half = (theta / 2.0).cos();
|
||||
let bis = (a + b).normalized()?;
|
||||
let m = s.c + bis * (a.len() / half);
|
||||
Some([h(s.q1, 1.0), h(m, half), h(s.q2, 1.0)])
|
||||
}
|
||||
|
||||
/// Blend the closed chain of edges (ids in any order) with radius `r`.
|
||||
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<Solid> {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let shells = solid.boundaries();
|
||||
let (si, shell) = shells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, sh)| sh.edge_iter().any(|e| ids.contains(&e.id())))
|
||||
.ok_or_else(|| KernelError::Failed("edge not found".into()))?;
|
||||
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
// Order the chain: edges as stored (absolute direction), each starting where the last ended.
|
||||
let mut chain: Vec<mt::Edge> = Vec::new();
|
||||
let all: Vec<mt::Edge> = {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
shell.edge_iter().filter(|e| ids.contains(&e.id()) && seen.insert(e.id())).map(|e| e.absolute_clone()).collect()
|
||||
};
|
||||
let first = all.first().cloned().ok_or_else(|| KernelError::Failed("no edges".into()))?;
|
||||
chain.push(first.clone());
|
||||
while chain.len() < all.len() {
|
||||
let end = chain.last().map(|e| e.back().clone()).ok_or_else(|| KernelError::Failed("chain".into()))?;
|
||||
let next = all
|
||||
.iter()
|
||||
.find(|e| !chain.iter().any(|c| c.id() == e.id()) && (*e.front() == end || *e.back() == end))
|
||||
.ok_or_else(|| unsupported("the edges don't form one closed chain"))?;
|
||||
chain.push(if *next.front() == end { next.clone() } else { next.inverse() });
|
||||
}
|
||||
if chain.first().map(|e| e.front().clone()) != chain.last().map(|e| e.back().clone()) {
|
||||
return Err(unsupported("blending an open chain of curved edges"));
|
||||
}
|
||||
let n = chain.len();
|
||||
// Faces of each edge, split into the two sides.
|
||||
let faces_of = |e: &mt::Edge| -> Vec<usize> {
|
||||
(0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.edge_iter().any(|x| x.id() == e.id()))).collect()
|
||||
};
|
||||
let mut side1: Vec<usize> = Vec::new();
|
||||
let mut side2: Vec<usize> = Vec::new();
|
||||
for (k, e) in chain.iter().enumerate() {
|
||||
let fs = faces_of(e);
|
||||
let [a, b] = fs[..] else { return Err(unsupported("an edge without two faces")) };
|
||||
if k == 0 {
|
||||
side1.push(a);
|
||||
side2.push(b);
|
||||
continue;
|
||||
}
|
||||
// The side-1 face of this edge is the one whose normal matches side 1 at the joint.
|
||||
let p = from_p3(e.front().point());
|
||||
let nrm = |fi: usize| faces.get(fi).and_then(|f| project(f, p, None)).map(|x| x.1);
|
||||
let prev1 = side1.last().copied().and_then(nrm);
|
||||
let (na, nb) = (nrm(a), nrm(b));
|
||||
let a_is_1 = match (prev1, na, nb) {
|
||||
(Some(x), Some(y), Some(z)) => x.dot(y) >= x.dot(z),
|
||||
_ => return Err(KernelError::Failed("blend: normals".into())),
|
||||
};
|
||||
if a_is_1 {
|
||||
side1.push(a);
|
||||
side2.push(b);
|
||||
} else {
|
||||
side1.push(b);
|
||||
side2.push(a);
|
||||
}
|
||||
}
|
||||
let s1: Vec<&mt::Face> = side1.iter().filter_map(|i| faces.get(*i)).collect();
|
||||
let s2: Vec<&mt::Face> = side2.iter().filter_map(|i| faces.get(*i)).collect();
|
||||
// Convex or concave, from the first edge as its side-1 face runs it.
|
||||
let e0 = chain.first().ok_or_else(|| KernelError::Failed("chain".into()))?;
|
||||
let f1 = s1.first().ok_or_else(|| KernelError::Failed("side".into()))?;
|
||||
let in_f1 = f1.boundary_iters().into_iter().flatten().find(|e| e.id() == e0.id()).ok_or_else(|| KernelError::Failed("edge use".into()))?;
|
||||
let (pm, t1) = {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = in_f1.oriented_curve();
|
||||
let (a, b) = c.range_tuple();
|
||||
let m = (a + b) * 0.5;
|
||||
let d = c.der(m);
|
||||
(from_p3(c.subs(m)), Vec3::new(d.x, d.y, d.z).normalized().ok_or_else(|| KernelError::Failed("tangent".into()))?)
|
||||
};
|
||||
let (n1, n2) = match (project(f1, pm, None), s2.first().and_then(|f| project(f, pm, None))) {
|
||||
(Some(x), Some(y)) => (x.1, y.1),
|
||||
_ => return Err(KernelError::Failed("blend: normals".into())),
|
||||
};
|
||||
let convex = t1.dot(n1.cross(n2)) > 0.0;
|
||||
let sigma = if convex { -1.0 } else { 1.0 };
|
||||
if n1.dot(n2) > 1.0 - 1e-6 {
|
||||
return Err(unsupported("blending a smooth edge"));
|
||||
}
|
||||
// Seams at the chain's vertices: edges there that aren't in the chain, and their side.
|
||||
let chain_ids: Vec<mt::EdgeID> = chain.iter().map(|e| e.id()).collect();
|
||||
let mut seams: Vec<Vec<(usize, mt::Edge)>> = Vec::with_capacity(n);
|
||||
for e in &chain {
|
||||
let v = e.front().clone();
|
||||
let mut found: Vec<(usize, mt::Edge)> = Vec::new();
|
||||
for (fi, f) in faces.iter().enumerate() {
|
||||
for x in f.edge_iter() {
|
||||
if chain_ids.contains(&x.id()) || !(*x.front() == v || *x.back() == v) || found.iter().any(|(_, y)| y.id() == x.id()) {
|
||||
continue;
|
||||
}
|
||||
let side = if side1.contains(&fi) {
|
||||
1
|
||||
} else if side2.contains(&fi) {
|
||||
2
|
||||
} else {
|
||||
return Err(unsupported("a vertex of the edge chain where other faces meet"));
|
||||
};
|
||||
found.push((side, x.absolute_clone()));
|
||||
}
|
||||
}
|
||||
// At most one seam per side.
|
||||
if found.iter().filter(|x| x.0 == 1).count() > 1 || found.iter().filter(|x| x.0 == 2).count() > 1 {
|
||||
return Err(unsupported("a vertex of the edge chain where several seams meet"));
|
||||
}
|
||||
seams.push(found);
|
||||
}
|
||||
// Sections: at each vertex (a contact on the seam there), then along each edge.
|
||||
let tangent_at = |e: &mt::Edge, s: f64| -> Option<(Vec3, Vec3)> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.oriented_curve();
|
||||
let (a, b) = c.range_tuple();
|
||||
let t = a + (b - a) * s;
|
||||
let d = c.der(t);
|
||||
Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?))
|
||||
};
|
||||
let mut vsec: Vec<Section> = Vec::with_capacity(n);
|
||||
for (k, e) in chain.iter().enumerate() {
|
||||
let (p, t) = tangent_at(e, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
|
||||
let here = seams.get(k).cloned().unwrap_or_default();
|
||||
let extra = match here.first().cloned() {
|
||||
None => None,
|
||||
Some((side, seam)) => {
|
||||
// The plane through the seam square to that side's surface.
|
||||
let o = from_p3(seam.front().point());
|
||||
let d = (from_p3(seam.back().point()) - o).normalized().ok_or_else(|| KernelError::Failed("seam".into()))?;
|
||||
if !matches!(seam.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("a curved seam where the edge chain turns"));
|
||||
}
|
||||
let fs = if side == 1 { &s1 } else { &s2 };
|
||||
let nn =
|
||||
fs.iter().filter_map(|f| project(f, p, None)).map(|x| x.1).next().ok_or_else(|| KernelError::Failed("seam normal".into()))?;
|
||||
Some((side, o, d.cross(nn).normalized().ok_or_else(|| KernelError::Failed("seam plane".into()))?))
|
||||
}
|
||||
};
|
||||
let s = solve(&s1, &s2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
|
||||
// A seam on the other side too must pass through that side's contact.
|
||||
if let Some((side, seam)) = here.get(1) {
|
||||
let q = if *side == 1 { s.q1 } else { s.q2 };
|
||||
let (a, b) = (from_p3(seam.front().point()), from_p3(seam.back().point()));
|
||||
if !matches!(seam.curve(), mt::Curve::Line(_)) || q.dist_to_segment(a, b) > tol * 1e3 {
|
||||
return Err(unsupported("seams on both sides of a corner of the edge chain"));
|
||||
}
|
||||
}
|
||||
vsec.push(s);
|
||||
}
|
||||
let mut esec: Vec<Vec<Section>> = Vec::with_capacity(n);
|
||||
for (k, e) in chain.iter().enumerate() {
|
||||
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
|
||||
for j in 1..SAMPLES - 1 {
|
||||
let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?;
|
||||
list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?);
|
||||
}
|
||||
list.push(*vsec.get((k + 1) % n).ok_or_else(|| KernelError::Failed("section".into()))?);
|
||||
esec.push(list);
|
||||
}
|
||||
crate::guard("curve blend", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
|
||||
// Vertices and arcs at the chain's vertices.
|
||||
let v1: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q1))).collect();
|
||||
let v2: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q2))).collect();
|
||||
let mut arcs = Vec::with_capacity(n);
|
||||
for (k, s) in vsec.iter().enumerate() {
|
||||
let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
|
||||
let e = if round {
|
||||
let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * r;
|
||||
builder::circle_arc(a, b, p3(m))
|
||||
} else {
|
||||
builder::line(a, b)
|
||||
};
|
||||
arcs.push(e);
|
||||
}
|
||||
// Contact curves and blend faces per edge.
|
||||
let mut c1: Vec<mt::Edge> = Vec::with_capacity(n);
|
||||
let mut c2: Vec<mt::Edge> = Vec::with_capacity(n);
|
||||
let mut blends: Vec<mt::Surface> = Vec::with_capacity(n);
|
||||
for (k, list) in esec.iter().enumerate() {
|
||||
let q1: Vec<Vec3> = list.iter().map(|s| s.q1).collect();
|
||||
let q2: Vec<Vec3> = list.iter().map(|s| s.q2).collect();
|
||||
let k1 = (k + 1) % n;
|
||||
let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1));
|
||||
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
|
||||
let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?;
|
||||
let cv2 = crate::build::interpolate_cubic(&q2).ok_or_else(|| fail("contact curve"))?;
|
||||
let e1 = mt::Edge::new(a1, b1, mt::Curve::BSplineCurve(cv1));
|
||||
let e2 = mt::Edge::new(a2, b2, mt::Curve::BSplineCurve(cv2));
|
||||
// The blend surface: each control row interpolated along the edge (homogeneous),
|
||||
// through two sections of each neighbouring edge too, so that neighbouring blend
|
||||
// faces meet smoothly.
|
||||
let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
|
||||
let next = esec.get(k1).ok_or_else(|| fail("section"))?;
|
||||
let mut ext: Vec<Section> = Vec::with_capacity(list.len() + 4);
|
||||
if n > 1 || prev.len() > 3 {
|
||||
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
|
||||
}
|
||||
ext.extend(list.iter().copied());
|
||||
if n > 1 || next.len() > 3 {
|
||||
ext.extend(next.iter().skip(1).take(2).copied());
|
||||
}
|
||||
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
|
||||
let mut rows: Vec<Vec<mt::Vector4>> = Vec::with_capacity(3);
|
||||
let mut vknots = None;
|
||||
for j in 0..3 {
|
||||
let xyz: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].x, c[j].y, c[j].z)).collect();
|
||||
let ww: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].w, 0.0, 0.0)).collect();
|
||||
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
|
||||
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
|
||||
vknots = Some(bx.knot_vec().clone());
|
||||
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect());
|
||||
}
|
||||
let vk = vknots.ok_or_else(|| fail("knots"))?;
|
||||
let uk = mt::KnotVec::bezier_knot(2);
|
||||
let bs = mt::BSplineSurface::try_new((uk, vk), rows).map_err(|e| fail(&e.to_string()))?;
|
||||
let mut surface = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs));
|
||||
// Outward: toward the ball centre on a concave edge, away on a convex one.
|
||||
{
|
||||
use mt::{ParametricSurface3D, SearchNearestParameter};
|
||||
let s = list.get(SAMPLES / 2).ok_or_else(|| fail("section"))?;
|
||||
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
|
||||
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
|
||||
let nn = surface.normal(u, v);
|
||||
let want = (s.c - mid) * sigma;
|
||||
if Vec3::new(nn.x, nn.y, nn.z).dot(want) < 0.0 {
|
||||
surface = mt::Invertible::inverse(&surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
c1.push(e1);
|
||||
c2.push(e2);
|
||||
blends.push(surface);
|
||||
}
|
||||
// Rebuild the side faces: chain edges become contact curves, seams start at contacts.
|
||||
let mut subst: HashMap<mt::EdgeID, (mt::Edge, mt::Edge)> = HashMap::new();
|
||||
for (k, e) in chain.iter().enumerate() {
|
||||
let (Some(x), Some(y)) = (c1.get(k), c2.get(k)) else { return Err(fail("contact")) };
|
||||
subst.insert(e.id(), (x.clone(), y.clone()));
|
||||
}
|
||||
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (k, sm) in seams.iter().enumerate().flat_map(|(k, l)| l.iter().map(move |x| (k, x))) {
|
||||
let (side, seam) = sm;
|
||||
let v = chain.get(k).map(|e| e.front().clone()).ok_or_else(|| fail("vertex"))?;
|
||||
let nv = if *side == 1 { v1.get(k) } else { v2.get(k) }.ok_or_else(|| fail("vertex"))?;
|
||||
let ne = if *seam.front() == v { builder::line(nv, seam.back()) } else { builder::line(seam.front(), nv) };
|
||||
seam_subst.insert(seam.id(), ne);
|
||||
}
|
||||
let mut out: Vec<mt::Face> = Vec::new();
|
||||
for (fi, f) in faces.iter().enumerate() {
|
||||
let on1 = side1.contains(&fi);
|
||||
let on2 = side2.contains(&fi);
|
||||
if !on1 && !on2 && !f.edge_iter().any(|e| seam_subst.contains_key(&e.id())) {
|
||||
out.push(f.clone());
|
||||
continue;
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut es = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
let ne = if let Some((x, y)) = subst.get(&e.id()) {
|
||||
let base = if on1 { x } else { y };
|
||||
// Same direction as the chain edge, or reversed.
|
||||
let chain_e = chain.iter().find(|c| c.id() == e.id()).ok_or_else(|| fail("edge"))?;
|
||||
if e.front() == chain_e.front() { base.clone() } else { base.inverse() }
|
||||
} else 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);
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?);
|
||||
}
|
||||
// Blend faces: each runs its side-1 contact the other way from side 1's face.
|
||||
for (k, bf) in blends.iter().enumerate() {
|
||||
let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % n)) else {
|
||||
return Err(fail("blend"));
|
||||
};
|
||||
let f1i = side1.get(k).ok_or_else(|| fail("side"))?;
|
||||
let f1 = faces.get(*f1i).ok_or_else(|| fail("side"))?;
|
||||
let chain_e = chain.get(k).ok_or_else(|| fail("edge"))?;
|
||||
let used_fwd = f1
|
||||
.boundary_iters()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|e| e.id() == chain_e.id())
|
||||
.map(|e| e.front() == chain_e.front())
|
||||
.ok_or_else(|| fail("edge use"))?;
|
||||
// Side 1 runs the contact forward (as the chain) when it ran the edge forward.
|
||||
let wire: mt::Wire = if used_fwd {
|
||||
vec![e1.inverse(), ak.clone(), e2.clone(), ak1.inverse()].into()
|
||||
} else {
|
||||
vec![e1.clone(), ak1.clone(), e2.inverse(), ak.inverse()].into()
|
||||
};
|
||||
let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?;
|
||||
out.push(face);
|
||||
}
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = out.into();
|
||||
}
|
||||
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod blend;
|
||||
mod body;
|
||||
mod build;
|
||||
mod coplanar;
|
||||
mod curveblend;
|
||||
mod freeform;
|
||||
mod heal;
|
||||
mod helix;
|
||||
|
||||
@@ -1060,3 +1060,22 @@ fn torus_cut_through_its_middle_plane() {
|
||||
let v = measure(&i).unwrap().volume;
|
||||
assert!(rel(v, PI * 75.0 * 10.0) < 1e-4, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_where_a_branch_pipe_meets_a_main_pipe() {
|
||||
// A branch (radius 10) on a main pipe (radius 15): the saddle curve between the two
|
||||
// cylinders, blended by rolling a ball of radius 3 along it.
|
||||
let main = cylinder(Vec3::new(-40.0, 0.0, 0.0), Vec3::X, 15.0, 80.0).unwrap();
|
||||
let branch = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 35.0).unwrap();
|
||||
let tee = boolean(&main, &branch, BoolOp::Union).unwrap().unwrap();
|
||||
let v0 = measure(&tee).unwrap().volume;
|
||||
let f = fillet(&tee, &[Vec3::new(0.0, 10.0, 125f64.sqrt())], 3.0).unwrap();
|
||||
let m = measure(&f).unwrap();
|
||||
// The added material: less than r²(1 − π/4) (a right-angled corner's) swept round the
|
||||
// ≈ 65 mm saddle, as the pipes meet at obtuse angles, but well over a third of it.
|
||||
let bare = 9.0 * (1.0 - PI / 4.0) * 65.0;
|
||||
let added = m.volume - v0;
|
||||
assert!(added > 0.35 * bare && added < bare, "added {added} (bare {bare})");
|
||||
// Main cylinder, branch cylinder, the blend, the branch's top and the main's two ends.
|
||||
assert_eq!(m.merged.faces, 6, "{:?}", m.merged);
|
||||
}
|
||||
@@ -397,11 +397,37 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
edge_verts.insert(ei, (a, bb));
|
||||
}
|
||||
}
|
||||
// Merge faces.
|
||||
// Merge faces. Free-form faces meeting along an edge: smooth by their exact normals.
|
||||
let flist: Vec<&truck_modeling::Face> = solid.face_iter().collect();
|
||||
let exact_smooth = |a: usize, b: usize, edge: Option<&Vec<Vec3>>| -> bool {
|
||||
use truck_modeling::{ParametricSurface3D, SearchNearestParameter};
|
||||
let (Some(fa), Some(fb), Some(e)) = (flist.get(a), flist.get(b), edge) else { return false };
|
||||
let (sa, sb) = (fa.oriented_surface(), fb.oriented_surface());
|
||||
let step = (e.len() / 8).max(1);
|
||||
e.iter().step_by(step).all(|p| {
|
||||
let q = crate::body::p3(*p);
|
||||
match (sa.search_nearest_parameter(q, None, 50), sb.search_nearest_parameter(q, None, 50)) {
|
||||
(Some((u, v)), Some((s, t))) => {
|
||||
let (x, y) = (sa.normal(u, v), sb.normal(s, t));
|
||||
x.x * y.x + x.y * y.y + x.z * y.z > 1.0 - 1e-4
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
};
|
||||
let mut fp: Vec<usize> = (0..nf).collect();
|
||||
for (ei, faces) in &edge_faces {
|
||||
let other = |f: usize| matches!(surfs.get(f), Some(Surf::Other(_)));
|
||||
if let [a, b] = faces[..]
|
||||
&& same_surface(&surfs, &verts, mesh.edges.get(*ei), a, b, tol * 10.0)
|
||||
&& (same_surface(&surfs, &verts, mesh.edges.get(*ei), a, b, tol * 10.0)
|
||||
|| (other(a) && other(b) && exact_smooth(a, b, mesh.edges.get(*ei)) && {
|
||||
let (Some(va), Some(vb)) = (verts.get(a), verts.get(b)) else { return Ok(TopoCounts::default()) };
|
||||
match (cone_signature(va), cone_signature(vb)) {
|
||||
(Some((xa, ca)), Some((xb, cb))) => xa.dot(xb) > 1.0 - 1e-4 && (ca - cb).abs() < 1e-3,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}))
|
||||
{
|
||||
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
|
||||
if let Some(s) = fp.get_mut(ra) {
|
||||
|
||||
Reference in new issue
Block a user