mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
kernel: fillets and chamfers run on SMLib, their fit checked on the record
fillet, fillet_each, fillet_g2, chamfer and chamfer_sides blend every picked edge in one SMLib call (smlib_blend / smlib_chamfer_sides), which builds the rounds, their tangent chains and the corners where they meet. The #1080 fit check (blend_fit) reads the body's schema 4 record: planar faces' outward normals, the direction into a face across a straight edge, edge tangents at the ends. It now runs before SMLib, so a blend that runs past its faces is TooLarge with the largest size that fits (fillet_max_each, the taper and the doc's reduce path rely on that), and the volume and wrong-way checks after it. fillet_max_each groups the edges by their faces and ends in the record. fillet_g2_corners is refused: SMLib's corners are a sphere or a Coons patch, not G2. The local truck blends, their reorder search and every truck helper they used are gone. Tests: blend.rs's tests call fillet/chamfer instead of the deleted internal `blend`; the assertions on the deleted quintic construction's surface and curve parameters and on extension_fits (deleted) are removed, the analytic volumes kept. (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:
+222
-2590
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,8 @@
|
||||
//! that does fit. After the build, the volume taken away is checked against the wedges too.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::blend::{ChamferSide, plane_normal, vtx};
|
||||
use crate::blend::{ChamferSide, Faces};
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
@@ -61,7 +60,7 @@ struct Frame {
|
||||
/// The faces (indices in the body's face order) the blend may meet: the two beside the
|
||||
/// edge and every face at its ends.
|
||||
allowed: Vec<usize>,
|
||||
ends: [mt::VertexID; 2],
|
||||
ends: [u32; 2],
|
||||
/// Another edge of the two faces runs on from an end tangentially: the blend runs on along
|
||||
/// that chain past this edge.
|
||||
chained: bool,
|
||||
@@ -262,21 +261,15 @@ struct Selection {
|
||||
coarse: (f64, f64),
|
||||
}
|
||||
|
||||
/// The selection on `body` as SMLib blends it: its record's faces and edges, in their order (the
|
||||
/// mesh's edges and faces are in the same order).
|
||||
fn select(body: &Body, pts: &[Vec3]) -> Result<Selection> {
|
||||
// As the blends take it: coplanar neighbours as one face, collinear pieces as one edge.
|
||||
let healed = crate::boolean_record::healed(body)?;
|
||||
crate::guard("blend fit", || select_in(&healed, pts))
|
||||
}
|
||||
|
||||
fn select_in(body: &Body, pts: &[Vec3]) -> Result<Selection> {
|
||||
let size = body.size();
|
||||
let chord = (size * 1e-3).max(1e-4);
|
||||
let mesh = body.mesh(&crate::MeshTolerance::chord(chord))?;
|
||||
let solid = &*body.solid;
|
||||
let faces: Vec<&mt::Face> = solid.face_iter().collect();
|
||||
let planar: Vec<bool> = faces.iter().map(|f| plane_normal(f).is_some()).collect();
|
||||
let all = Body::unique_edges(solid);
|
||||
let mut taken: Vec<mt::EdgeID> = Vec::new();
|
||||
let rec = Faces::of(body)?;
|
||||
let planar: Vec<bool> = (0..rec.rec.faces.len()).map(|f| rec.plane_normal(f).is_some()).collect();
|
||||
let mut taken: Vec<usize> = Vec::new();
|
||||
let mut frames = Vec::new();
|
||||
for p in pts {
|
||||
// The edge nearest the point, as the blends pick it.
|
||||
@@ -286,12 +279,12 @@ fn select_in(body: &Body, pts: &[Vec3]) -> Result<Selection> {
|
||||
.enumerate()
|
||||
.map(|(i, poly)| (i, poly.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
// Points on the same edge (pieces healed into one) count once.
|
||||
let frame = match best.and_then(|(i, dist)| all.get(i).filter(|_| dist <= size * 0.05 + 1e-3)) {
|
||||
Some(e) if taken.contains(&e.id()) => continue,
|
||||
Some(e) => {
|
||||
taken.push(e.id());
|
||||
frame(&faces, e)
|
||||
// Points on the same edge count once.
|
||||
let frame = match best.filter(|(i, dist)| *i < rec.rec.edges.len() && *dist <= size * 0.05 + 1e-3) {
|
||||
Some((e, _)) if taken.contains(&e) => continue,
|
||||
Some((e, _)) => {
|
||||
taken.push(e);
|
||||
frame(&rec, e)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -303,38 +296,39 @@ fn select_in(body: &Body, pts: &[Vec3]) -> Result<Selection> {
|
||||
Ok(Selection { frames, tris, planar, size, chord, coarse })
|
||||
}
|
||||
|
||||
fn frame(faces: &[&mt::Face], e: &mt::Edge) -> Option<Frame> {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
fn frame(rec: &Faces, e: usize) -> Option<Frame> {
|
||||
if !rec.straight(e) {
|
||||
return None;
|
||||
}
|
||||
let id = e.id();
|
||||
let ends = [e.front().id(), e.back().id()];
|
||||
let (p0, p1) = (vtx(e.absolute_front()), vtx(e.absolute_back()));
|
||||
let ends = rec.rec.edges.get(e)?.vertices;
|
||||
let (p0, p1) = rec.ends(e)?;
|
||||
let d = (p1 - p0).normalized()?;
|
||||
// Each face along the edge: its outward normal and the direction into it (its boundary runs
|
||||
// counter-clockwise about the normal, so the face lies to the left of the edge's use).
|
||||
// Each face along the edge: its outward normal and the direction into it (the face lies to
|
||||
// the left of its loop seen from its surface's natural normal).
|
||||
let mut beside = Vec::new();
|
||||
let mut allowed = Vec::new();
|
||||
let mut chained = false;
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let mut uses: Vec<mt::Edge> = f.boundaries().iter().flat_map(|w| w.edge_iter()).filter(|x| x.id() == id).cloned().collect();
|
||||
let at_end = f.boundaries().iter().flat_map(|w| w.edge_iter()).any(|x| ends.contains(&x.front().id()) || ends.contains(&x.back().id()));
|
||||
for i in 0..rec.rec.faces.len() {
|
||||
let at_end = rec.uses(i).any(|x| rec.rec.edges.get(x.edge as usize).is_some_and(|y| y.vertices.iter().any(|v| ends.contains(v))));
|
||||
if at_end {
|
||||
allowed.push(i);
|
||||
}
|
||||
let mut uses: Vec<_> = rec.uses(i).filter(|x| x.edge as usize == e).collect();
|
||||
if uses.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let n = plane_normal(f)?;
|
||||
let n = rec.plane_normal(i)?;
|
||||
let u = uses.pop()?;
|
||||
if !uses.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dir = (vtx(u.back()) - vtx(u.front())).normalized()?;
|
||||
beside.push((n, n.cross(dir).normalized()?));
|
||||
chained |= f.boundaries().iter().flat_map(|w| w.edge_iter()).filter(|x| x.id() != id).any(|x| {
|
||||
ends.iter().any(|v| *v == x.front().id() && tangent_at(x, false).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6))
|
||||
|| ends.iter().any(|v| *v == x.back().id() && tangent_at(x, true).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6))
|
||||
beside.push((n, rec.into_face(i, u)?));
|
||||
chained |= rec.uses(i).filter(|x| x.edge as usize != e).any(|x| {
|
||||
let Some(other) = rec.rec.edges.get(x.edge as usize) else { return false };
|
||||
let [a, b] = other.vertices;
|
||||
let x = x.edge as usize;
|
||||
(ends.contains(&a) && rec.tangent(x, false).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6))
|
||||
|| (ends.contains(&b) && rec.tangent(x, true).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6))
|
||||
});
|
||||
}
|
||||
let [(na, ta), (nb, tb)] = beside[..] else { return None };
|
||||
@@ -345,15 +339,6 @@ fn frame(faces: &[&mt::Face], e: &mt::Edge) -> Option<Frame> {
|
||||
Some(Frame { p0, p1, ta, tb, na, nb, allowed, ends, chained })
|
||||
}
|
||||
|
||||
/// The unit tangent of an edge (as used) at its start or end.
|
||||
fn tangent_at(e: &mt::Edge, at_end: bool) -> Option<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.oriented_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()
|
||||
}
|
||||
|
||||
/// What a blend that doesn't fit runs into.
|
||||
enum Clash {
|
||||
Face,
|
||||
@@ -431,17 +416,9 @@ fn step(v: f64) -> f64 {
|
||||
10f64.powf(v.log10().floor() - 3.0)
|
||||
}
|
||||
|
||||
/// Why a blend of `edges` at `size` doesn't fit `body` and the largest size that does (to
|
||||
/// three significant digits; `None` when not even the smallest step fits), or `None` when it
|
||||
/// fits or the check can't tell (edges other than straight ones between planar faces).
|
||||
fn misfit(body: &Body, edges: &[Vec3], size: Size) -> Result<Option<(Clash, Option<f64>)>> {
|
||||
if body.is_mesh() || edges.is_empty() || !size.value().is_finite() || size.value() <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
misfit_in(&select(body, edges)?, size)
|
||||
}
|
||||
|
||||
/// [`misfit`] on the selection.
|
||||
/// Why a blend at `size` doesn't fit the selection and the largest size that does (to three
|
||||
/// significant digits; `None` when not even the smallest step fits), or `None` when it fits or
|
||||
/// the check can't tell (edges other than straight ones between planar faces).
|
||||
fn misfit_in(sel: &Selection, size: Size) -> Result<Option<(Clash, Option<f64>)>> {
|
||||
if sel.frames.iter().all(Option::is_none) || !size.value().is_finite() || size.value() <= 0.0 {
|
||||
return Ok(None);
|
||||
@@ -493,27 +470,19 @@ fn check_in(sel: &Selection, size: Size) -> Result<()> {
|
||||
Err(KernelError::TooLarge(format!("{} mm is too large for this {what}: {into}{}", show(size.value()), fits_note(size, largest))))
|
||||
}
|
||||
|
||||
/// A blend's refusal `e`, with the largest size that fits added when the size is why.
|
||||
pub(crate) fn explain(body: &Body, edges: &[Vec3], size: Size, e: KernelError) -> KernelError {
|
||||
let note = match misfit(body, edges, size) {
|
||||
Ok(Some((_, largest @ Some(_)))) => fits_note(size, largest),
|
||||
_ => return e,
|
||||
};
|
||||
match e {
|
||||
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m}{note}")),
|
||||
KernelError::Failed(m) => KernelError::Failed(format!("{m}{note}")),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A blend of `edges` at `size` that turned `input` into `out`, if it fits ([`check`]) and
|
||||
/// changes no more than it can ([`check_volume`]).
|
||||
pub(crate) fn accept(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result<Body> {
|
||||
if input.is_mesh() || out.is_mesh() || edges.is_empty() {
|
||||
return Ok(out);
|
||||
/// The blend `build` makes of `edges` at `size` on `input`: refused before it is built when it
|
||||
/// would not fit ([`check`]), and after when it changes more than the blends can
|
||||
/// ([`check_volume`]) or goes the wrong way.
|
||||
pub(crate) fn fit(input: &Body, edges: &[Vec3], size: Size, build: impl FnOnce() -> Result<Body>) -> Result<Body> {
|
||||
if input.is_mesh() || edges.is_empty() {
|
||||
return build();
|
||||
}
|
||||
let sel = select(input, edges)?;
|
||||
check_in(&sel, size)?;
|
||||
let out = build()?;
|
||||
if out.is_mesh() {
|
||||
return Ok(out);
|
||||
}
|
||||
let out = volume_in(&sel, input, out, size)?;
|
||||
right_way(input, out, edges, size)
|
||||
}
|
||||
@@ -523,22 +492,22 @@ pub(crate) fn accept(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Res
|
||||
/// directions into each face across the edge at `p`. `None` unless that is clear-cut: faces
|
||||
/// meeting at a few degrees of tangent or of a knife edge, an edge not between two faces.
|
||||
fn convex_at(body: &Body, p: Vec3) -> Option<bool> {
|
||||
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
|
||||
let tol = (body.size() * 1e-4).max(1e-4);
|
||||
let (i, _) = body.nearest_edge(p, tol).ok()??;
|
||||
let edge = Body::unique_edges(&body.solid).into_iter().nth(i)?;
|
||||
let rec = Faces::of(body).ok()?;
|
||||
let edge = rec.rec.edges.get(i)?;
|
||||
let t = nearest_parameter(&edge.curve, edge.interval, p)?;
|
||||
let along = crate::blend::curve_tangent(&edge.curve, t)?;
|
||||
let on = solidcraft_geom::brep::eval::CurveEval::new(&edge.curve).ok()?.point(t)?;
|
||||
let accept = (body.size() * 1e-3).max(1e-3);
|
||||
let mut sides = Vec::new();
|
||||
for f in body.solid.face_iter() {
|
||||
for e in f.boundary_iters().into_iter().flatten().filter(|e| e.id() == edge.id()) {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let t = c.search_nearest_parameter(crate::body::p3(p), None, 100)?.clamp(t0.min(t1), t0.max(t1));
|
||||
let d = c.der(t);
|
||||
let dir = Vec3::new(d.x, d.y, d.z).normalized()?;
|
||||
let (_, n, _) = crate::curveblend::project(f, p, None)?;
|
||||
// A face's boundary runs counter-clockwise about its outward normal: its inside is
|
||||
// to the left of the edge.
|
||||
sides.push((n, n.cross(dir).normalized()?));
|
||||
for f in rec.faces_of(i) {
|
||||
let f = f as usize;
|
||||
for u in rec.uses(f).filter(|u| u.edge as usize == i) {
|
||||
let dir = if u.forward { along } else { along * -1.0 };
|
||||
// The face lies to the left of its loop seen from its surface's natural normal.
|
||||
let (n, natural) = (rec.normal_at(f, on, accept)?, rec.natural_normal_at(f, on, accept)?);
|
||||
sides.push((n, natural.cross(dir).normalized()?));
|
||||
}
|
||||
}
|
||||
let [(n1, t1), (n2, t2)] = sides[..] else { return None };
|
||||
@@ -588,6 +557,30 @@ fn right_way(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result<Body
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The parameter of the point of `c` within `interval` nearest `p`: the nearest of 33 evenly
|
||||
/// spaced starts, refined by Gauss-Newton steps on the distance.
|
||||
fn nearest_parameter(c: &solidcraft_geom::brep::Curve, interval: [f64; 2], p: Vec3) -> Option<f64> {
|
||||
let e = solidcraft_geom::brep::eval::CurveEval::new(c).ok()?;
|
||||
let [lo, hi] = interval;
|
||||
if !(lo.is_finite() && hi.is_finite() && lo < hi) {
|
||||
return None;
|
||||
}
|
||||
let at = |k: usize| lo + (hi - lo) * k as f64 / 32.0;
|
||||
let mut t = (0..=32).map(at).filter_map(|t| Some((t, e.point(t)?.dist(p)))).min_by(|a, b| a.1.total_cmp(&b.1))?.0;
|
||||
for _ in 0..32 {
|
||||
let (q, d) = (e.point(t)?, crate::blend::curve_derivative(c, t)?);
|
||||
if !(d.len2() > 0.0) {
|
||||
break;
|
||||
}
|
||||
let step = (p - q).dot(d) / d.len2();
|
||||
t = (t + step).clamp(lo, hi);
|
||||
if step.abs() <= (hi - lo) * 1e-12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// After a blend of `edges` at `size` turned `input` into `out`: refused when the volume taken
|
||||
/// away (or added) is more than the blends' wedges hold, which only a result that ran into
|
||||
/// other geometry does (#871's shelled box lost 240 mm³ more than any round could take).
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Fillets and chamfers through SMLib, when [`crate::backend`] selects it.
|
||||
//! Fillets and chamfers through SMLib.
|
||||
//!
|
||||
//! Each kernel entry point ([`crate::fillet`], [`crate::chamfer`], …) asks [`selected`] first and,
|
||||
//! when SMLib is the kernel, hands its arguments here unchanged: the body goes into SMLib as its
|
||||
//! schema 4 record (healed where SMLib's check refuses it as given), tagged as operand 0; each
|
||||
//! point picks the body's nearest edge in its own edge order, as truck's blends do, and SMLib is
|
||||
//! given that edge's midpoint, which no other edge is as near to (a point at a vertex between two
|
||||
//! edges would leave SMLib to choose); SMLib blends those edges with their tangent chains, its check must pass, and the result comes back decoded with its
|
||||
//! geometry checked. An option SMLib has no construction for is refused as "not supported yet",
|
||||
//! never built by truck instead, so a comparison never compares truck with itself.
|
||||
//! Each kernel entry point ([`crate::fillet`], [`crate::chamfer`], …) hands its arguments here
|
||||
//! unchanged: the body goes into SMLib as its schema 4 record (healed where SMLib's check refuses
|
||||
//! it as given), tagged as operand 0; each point picks the body's nearest edge in its own edge
|
||||
//! order, and SMLib is given that edge's midpoint, which no other edge is as near to (a point at
|
||||
//! a vertex between two edges would leave SMLib to choose); SMLib blends those edges with their
|
||||
//! tangent chains and the corners where they meet, its check must pass, and the result comes
|
||||
//! back decoded with its geometry checked. An option SMLib has no construction for is refused as
|
||||
//! "not supported yet".
|
||||
//!
|
||||
//! The history is SMLib's tags, renumbered into the body's orders; a blend face, which SMLib
|
||||
//! leaves untagged, comes from the edge it replaces ([`solidcraft_smlib::history::blends`]).
|
||||
@@ -18,13 +18,7 @@ use crate::blend_variable::RadiusSpline;
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Whether SMLib runs the blends ([`crate::backend`]). A setting naming SMLib in a build without
|
||||
/// it, or naming no kernel, is the error it is for every operation.
|
||||
pub(crate) fn selected() -> Result<bool> {
|
||||
Ok(crate::backend()? == crate::Backend::Smlib)
|
||||
}
|
||||
|
||||
/// What SMLib cannot build yet: refused rather than built by truck.
|
||||
/// What SMLib cannot build yet.
|
||||
pub(crate) fn unsupported(what: &str) -> KernelError {
|
||||
KernelError::Invalid(format!("not supported yet: {what} with the SMLib kernel"))
|
||||
}
|
||||
@@ -95,7 +89,7 @@ mod imp {
|
||||
KernelError::Failed(format!("SMLib {what}: {step}: {e}"))
|
||||
}
|
||||
|
||||
/// How far a pick may lie from its edge: as far as truck's blends accept one.
|
||||
/// How far a pick may lie from its edge.
|
||||
fn pick_tolerance(body: &Body) -> f64 {
|
||||
body.size() * 0.05 + 1e-3
|
||||
}
|
||||
@@ -235,7 +229,7 @@ mod imp {
|
||||
}
|
||||
|
||||
/// `h` with each blend face still without origin (a corner patch where blends meet) made in
|
||||
/// place of all the edges of the blend faces next to it, as truck records a corner, and every
|
||||
/// place of all the edges of the blend faces next to it, and every
|
||||
/// edge of a face new to the blend recorded as where its faces meet (two chamfers' mitre is
|
||||
/// where the two chamfers meet).
|
||||
fn with_corners(mut h: History) -> History {
|
||||
@@ -361,7 +355,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_pick_at_a_vertex_takes_the_nearest_edge_by_its_midpoint() {
|
||||
// (20, 20, 10) is on the vertical edge only; (20, 20, 20) is a corner of three edges, which
|
||||
// the body's own order resolves as truck's blends do, never left to SMLib.
|
||||
// the body's own order resolves, never left to SMLib.
|
||||
let b = cube();
|
||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(20.0, 20.0, 20.0)], &[1.0]).is_ok());
|
||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(60.0, 60.0, 60.0)], &[1.0]).is_err());
|
||||
@@ -441,7 +435,7 @@ mod tests {
|
||||
let saved = crate::decode_native_body(&crate::encode_native_body(&r).unwrap()).unwrap();
|
||||
assert!(crate::validity(&saved).record.is_ok());
|
||||
assert!(
|
||||
crate::validity(&saved.placed(&truck_modeling::Matrix4::from_translation(truck_modeling::Vector3::new(5.0, 0.0, 0.0))).unwrap())
|
||||
crate::validity(&crate::transform_matrix(&saved, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [5.0, 0.0, 0.0, 1.0]]).unwrap())
|
||||
.record
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user