Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 06:26:27 -04:00
1 parent 3126962cd4
commit bbd4f29ebb
43 files changed
+4753 -132

No files matched your search

+67 -2
View File
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -1051,10 +1051,26 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
if !fit.is_exact() && !style.is_constant() {
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius"));
}
// What fills the corners where three blended edges meet.
let corner = match str_(p, "corner").map(str::to_ascii_lowercase).as_deref() {
None | Some("rolling") => solidcraft_doc::FilletCorner::Rolling,
Some("g2") => solidcraft_doc::FilletCorner::G2,
Some(o) => return Err(bad(cmd, format!("unknown corner `{o}` (rolling or g2)"))),
};
if !corner.is_rolling() && (!style.is_constant() || !fit.is_exact()) {
return Err(bad(cmd, "`corner: g2` is built for a constant-radius fillet (`type: constant`, `fit: exact`) only"));
}
let mut out = add_feature(
s,
p,
FeatureKind::Fillet { edges: edges.into_iter().map(Position).collect(), radius, body: str_(p, "body").map(BodyName::from), style, fit },
FeatureKind::Fillet {
edges: edges.into_iter().map(Position).collect(),
radius,
body: str_(p, "body").map(BodyName::from),
style,
fit,
corner,
},
)?;
// The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64)
@@ -2042,6 +2058,55 @@ mod thin_extrude_tests;
#[cfg(test)]
#[path = "extrude_convention_tests.rs"]
mod extrude_convention_tests;
/// `solid.fillet corner?: rolling|g2`: the default keeps the rolling ball's sphere corner, `g2`
/// is accepted and refuses (item 10's patch is built but not sewn in yet, see `corner_g2`), and an
/// unknown word is refused.
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod corner_tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: &Value) -> Value {
s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// A 20 mm box with its three edges at one corner, filleted by 4 with `corner`.
fn cornered(corner: Option<&str>) -> (Session, Value) {
let mut s = Session::default();
run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"}));
let mut p = json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4});
if let Some(c) = corner {
p["corner"] = json!(c);
}
let reply = run(&mut s, "solid.fillet", &p);
(s, reply)
}
#[test]
fn the_default_corner_is_the_rolling_ball_and_g2_refuses_clearly() {
let (default, reply) = cornered(None);
assert_eq!(reply["warning"], json!(null), "{reply}");
let (rolling, _) = cornered(Some("rolling"));
let ball = default.world_state().body("B").unwrap().body.clone();
let same = rolling.world_state().body("B").unwrap().body.clone();
// `corner: rolling` is the default: the same body, to the last bit of its area.
assert_eq!(solidcraft_kernel::measure(&ball).unwrap().area, solidcraft_kernel::measure(&same).unwrap().area);
assert!(solidcraft_kernel::measure(&ball).unwrap().volume > 7000.0);
// `corner: g2`: accepted, and refused with a clear message until the patch sews in.
let mut s = Session::default();
run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"}));
let err =
s.execute("solid.fillet", &json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4, "corner": "g2"})).unwrap_err().to_string();
eprintln!("corner g2: {err}");
assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}");
// An unknown word, and a style the G2 corner has no patch for, are refused as parameters.
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "round"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "type": "chord"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "fit": "reduce"})).is_err());
}
}
#[cfg(test)]
#[path = "fillet_connected_rounds_tests.rs"]
mod fillet_connected_rounds_tests;
+480 -5
View File
@@ -88,7 +88,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
.params(concat!(
"a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)",
created_doc!()
)),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.at("SKETCH", "CREATE")
.icon("centerline")
@@ -399,8 +402,68 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> {
sk.curves.iter().enumerate().filter(|(_, c)| !c.construction).find_map(|(i, _)| leaving_dir(sk, i, pi).map(|d| (i, d)))
}
/// The continuity a blend asks for: the number of derivatives of each reference curve it
/// matches at both joints (g1 is the tangent construction the tool has always made).
fn blend_order(p: &Value, cmd: &str) -> Result<usize> {
match p.get("continuity") {
None | Some(Value::Null) => Ok(1),
Some(Value::String(v)) => match v.trim().to_ascii_lowercase().as_str() {
"" | "g1" => Ok(1),
"g2" => Ok(2),
"g3" => Ok(3),
"g4" => Ok(4),
_ => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")),
},
Some(_) => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")),
}
}
/// Negate the odd-order derivatives: how a curve's derivatives change when it is read backwards.
fn flip_odd(d: &mut [Vec2]) {
for (i, v) in d.iter_mut().enumerate() {
if i % 2 == 0 {
*v = -*v;
}
}
}
/// Derivatives 1…`order` of curve `ci`'s continuation through its end point `pi`, in the
/// parameterisation the blend needs at that joint: at the blend's start (`leaving`) the curve's
/// continuation runs away from it, at the blend's end the blend runs into the curve instead, which
/// is the same curve read backwards. The derivatives are those of the one exact piece that ends
/// at `pi`, evaluated at that piece's own end, so an arc gives its circular-motion terms (zero
/// curvature rate included) and a conic the quotient rule's terms. Errors when the curve has no
/// exact piece, when a derivative is not finite (a corrupt or degenerate reference), or when it
/// has no tangent at all: no blend can meet the last case.
fn joint_derivatives(sk: &Sketch, ci: usize, pi: usize, order: usize, leaving: bool, what: &str, cmd: &str) -> Result<Vec<Vec2>> {
let id = sk.curves.get(ci).map(|c| c.id.as_str()).unwrap_or("?");
let (start, end) = sk.curves.get(ci).and_then(|c| c.kind.ends()).ok_or_else(|| bad(cmd, format!("`{id}` has no end points")))?;
let at_end = end == pi;
if !at_end && start != pi {
return Err(bad(cmd, format!("`{what}` is not an end point of `{id}`")));
}
let pieces = sk.exact_pieces(ci);
let seg = if at_end { pieces.last() } else { pieces.first() }.ok_or_else(|| bad(cmd, format!("`{id}` has no exact geometry")))?;
let mut d = seg
.derivatives(if at_end { 1.0 } else { 0.0 }, order)
.ok_or_else(|| bad(cmd, format!("`{id}` has no finite derivatives up to order {order} at `{what}`")))?;
// Reading the piece against its own direction, or reading the continuation into the curve
// rather than out of it, each reverse the parameterisation once and so flip the odd
// derivatives: the blend's start wants the continuation away from the reference's end (no
// flip) or backwards out of its start (flip); its end wants the mirror image of that, so a
// joint needs a flip exactly when its two readings disagree.
if at_end != leaving {
flip_odd(&mut d);
}
if !d.first().is_some_and(|t| t.len() > 0.0) {
return Err(bad(cmd, format!("`{id}` has no tangent at `{what}`")));
}
Ok(d)
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.blend_curve";
let order = blend_order(p, cmd)?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?;
let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?;
@@ -412,10 +475,34 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "the ends must differ"));
}
let first_new = sk.points.len();
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?;
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?;
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?];
let (sp, cons) = if order <= 1 {
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?;
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?;
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?];
(sp, cons)
} else {
// One Bézier of degree 2·order + 1 whose controls are the Hermite interpolation of the
// two references' derivatives, so its 1st…order-th derivatives are theirs at both
// joints. Tangency comes from the construction, so it needs no tangent constraints.
let da = joint_derivatives(sk, ca, pa, order, true, "a", cmd)?;
let db = joint_derivatives(sk, cb, pb, order, false, "b", cmd)?;
let mut pts: Vec<usize> = Vec::with_capacity(2 * order + 2);
let cps = solidcraft_geom::hermite_bezier(qa, qb, &da, &db)
.ok_or_else(|| bad(cmd, "the blend curve could not be built from the derivatives of `a` and `b`"))?;
for (i, q) in cps.iter().enumerate() {
pts.push(if i == 0 {
pa
} else if i + 1 == cps.len() {
pb
} else {
sk.add_point(*q, None)?
});
}
let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?;
let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false }, None)?;
(sp, Vec::new())
};
solve_new_only(sk, first_new);
Ok((ids_of(sk, &[sp]), cons))
})?;
@@ -860,3 +947,391 @@ pub(super) fn solve_new_only(sk: &mut Sketch, first_new: usize) {
}
}
}
#[cfg(test)]
mod blend_tests {
use serde_json::{Value, json};
use solidcraft_geom::{Seg2, Vec2};
use solidcraft_sketch::CurveKind;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn ids(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
let id = s.active_sketch.unwrap();
s.model.state().sketch(id).unwrap().sketch.clone()
}
fn factorial(n: usize) -> f64 {
(1..=n).fold(1.0, |a, b| a * b as f64)
}
/// The derivatives 1…`order` of exact piece `seg` at its end (`at_end`), from the piece's own
/// geometry: what a blend must match, computed here independently of `Seg2::derivatives`.
fn piece_derivatives(seg: &Seg2, at_end: bool, order: usize) -> Vec<Vec2> {
let t = if at_end { 1.0 } else { 0.0 };
let p = seg.point_at(t);
let mut d = match *seg {
Seg2::Line { a, b } => vec![b - a, Vec2::ZERO, Vec2::ZERO, Vec2::ZERO],
Seg2::Arc { center, sweep, .. } => {
let (v, s2) = (seg.derivative(t), sweep * sweep);
vec![v, (p - center) * -s2, v * -s2, (p - center) * (s2 * s2)]
}
Seg2::Cubic { p0, p1, p2, p3 } => {
let third = (p3 - p2 * 3.0 + p1 * 3.0 - p0) * 6.0;
if at_end {
vec![(p3 - p2) * 3.0, (p3 - p2 * 2.0 + p1) * 6.0, third, Vec2::ZERO]
} else {
vec![(p1 - p0) * 3.0, (p0 - p1 * 2.0 + p2) * 6.0, third, Vec2::ZERO]
}
}
Seg2::Conic { a, apex, b, w } => {
// The rational quadratic's numerator N and weight D as quadratics in the power
// basis; dividing N's series by D's (constant term 1 at both ends) has to give the
// Taylor coefficients of the curve.
let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b);
let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w));
let h = 1e-3;
let series = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)];
let n = series(&|x| n0 + n1 * x + n2 * (x * x));
let den = [1.0 + e1 * t + e2 * (t * t), e1 + e2 * (2.0 * t), e2];
let mut x = vec![n[0] / den[0]];
for j in 1..=order {
let mut v = n.get(j).copied().unwrap_or(Vec2::ZERO);
for i in 1..=j {
v -= x[j - i] * den.get(i).copied().unwrap_or(0.0);
}
x.push(v / den[0]);
}
x.remove(0);
x.into_iter().enumerate().map(|(i, v)| v * factorial(i + 1)).collect()
}
Seg2::Bezier { .. } => panic!("a test piece is not a high-degree Bézier"),
};
d.truncate(order);
d
}
/// The derivatives 1…`order` the blend's own control polygon carries at an end: the j-th
/// difference of the controls, scaled by `n!/(n − j)!` (the Bézier endpoint identity).
fn blend_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec<Vec2> {
let c: Vec<Vec2> = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() };
let n = c.len() - 1;
let (mut diffs, mut scale) = (c, 1.0);
let mut out = Vec::new();
for j in 1..=order {
diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect();
scale *= (n + 1 - j) as f64;
out.push(diffs.first().copied().unwrap_or_default() * scale);
}
if at_end {
for (i, v) in out.iter_mut().enumerate() {
if i % 2 == 0 {
*v = -*v;
}
}
}
out
}
/// The blend curve's control points, its degree and the one exact piece they make.
fn blend_of(s: &Session, id: &str) -> (Vec<Vec2>, usize, Seg2) {
let sk = sketch(s);
let ci = sk.curve_index(id).unwrap();
let ctrl: Vec<Vec2> = sk.curves[ci].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
let degree = match sk.curves[ci].kind {
CurveKind::Spline { degree, .. } => usize::from(degree),
ref other => panic!("{other:?}"),
};
let pieces = sk.exact_pieces(ci);
assert_eq!(pieces.len(), 1, "{pieces:?}");
(ctrl, degree, pieces[0])
}
/// The reference pair the tests build: a line `(0, 0)`–`(6, 0)` ending at `a` and a three-point
/// control spline starting at `b`, so the blend runs out of the first and into the second with
/// no reversal at either joint.
fn two_references(s: &mut Session) -> (String, String, [Vec2; 4]) {
let a = ids(&run(s, "sketch.line", json!({"points": [[0, 0], [6, 0]]}))["curves"])[0].clone();
let bctrl = [Vec2::new(20.0, 10.0), Vec2::new(24.0, 14.0), Vec2::new(30.0, 9.0), Vec2::new(34.0, 6.0)];
let b = ids(&run(s, "sketch.spline.control_point", json!({"points": [[20, 10], [24, 14], [30, 9], [34, 6]]}))["curves"])[0].clone();
(a, b, bctrl)
}
#[test]
fn blend_curve_matches_both_references_through_the_requested_order() {
for order in 1..=4usize {
let mut s = new_sketch();
let (a, b, bctrl) = two_references(&mut s);
let r = run(
&mut s,
"sketch.blend_curve",
json!({
"a": format!("{a}.end"),
"b": format!("{b}.start"),
"continuity": format!("g{order}"),
}),
);
let sp = ids(&r["curves"])[0].clone();
let (ctrl, degree, piece) = blend_of(&s, &sp);
assert_eq!(degree, 2 * order + 1, "g{order} is one Bézier of degree 2k+1");
assert_eq!(ctrl.len(), 2 * order + 2, "g{order} control count");
assert!(r["sketch"]["solved"].as_bool().unwrap_or(false), "{r}");
assert!(piece.start().dist(Vec2::new(6.0, 0.0)) < 1e-12 && piece.end().dist(bctrl[0]) < 1e-12, "the blend joins `a` and `b`");
// What each reference supplies at the joint: the line's own derivative at its end (and
// nothing higher) and the cubic's at its start.
let line = piece_derivatives(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(6.0, 0.0) }, true, 4);
let cubic = piece_derivatives(&Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }, false, 4);
let got0 = blend_derivatives(&ctrl, false, 4);
let got1 = blend_derivatives(&ctrl, true, 4);
// g1 is the tangent construction of the tool: the first derivative is parallel to the
// reference's (that is what G1 is), with the tool's own chord-scaled magnitude. g2 and
// up match the reference's derivative vectors themselves.
let tan_line = line[0].normalized().unwrap();
let tan_cubic = cubic[0].normalized().unwrap();
assert!(got0[0].cross(tan_line).abs() < 1e-9 && got0[0].dot(tan_line) > 0.0, "g{order} leaves `a` along {tan_line:?}, not {:?}", got0[0]);
assert!(
got1[0].cross(tan_cubic).abs() < 1e-9 && got1[0].dot(tan_cubic) > 0.0,
"g{order} enters `b` along {tan_cubic:?}, not {:?}",
got1[0]
);
for j in 1..order {
assert!(got0[j].dist(line[j]) < 1e-9, "g{order} at a, order {}: {:?} vs {:?}", j + 1, got0[j], line[j]);
assert!(got1[j].dist(cubic[j]) < 1e-9, "g{order} at b, order {}: {:?} vs {:?}", j + 1, got1[j], cubic[j]);
}
if order >= 2 {
for (got, want) in [(got0[0], line[0]), (got1[0], cubic[0])] {
assert!(got.dist(want) < 1e-9, "g{order} matches the reference's own derivative: {got:?} vs {want:?}");
}
}
// The next order up is not met: the line has no derivative there at all, while a blend of
// degree 2k+1 has.
if order < 4 {
let next = got0[order];
assert!(next.dist(line[order]) > 1e-6, "g{order} matched one order too many at a: {next:?}");
}
// Curvature itself (orientation-free, from finite differences of the samples) agrees at
// both joints once the order asks for it.
if order >= 2 {
let curve = |x: f64| piece.point_at(x.clamp(0.0, 1.0));
let line_f = |x: f64| Vec2::new(6.0 * x, 0.0);
let cubic_f = |x: f64| Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }.point_at(x);
let k0 = solidcraft_sketch::end_curvature(&curve, false);
let ka = solidcraft_sketch::end_curvature(&line_f, true);
let k1 = solidcraft_sketch::end_curvature(&curve, true);
let kb = solidcraft_sketch::end_curvature(&cubic_f, false);
assert!(k0.dist(ka) < 1e-3, "g{order} curvature at a: {k0:?} vs {ka:?}");
assert!(k1.dist(kb) < 1e-3, "g{order} curvature at b: {k1:?} vs {kb:?}");
}
}
}
#[test]
fn blend_curve_g1_is_the_tangent_construction_with_its_constraints() {
let mut s = new_sketch();
let (a, b, bctrl) = two_references(&mut s);
let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
let sp = ids(&r["curves"])[0].clone();
let (ctrl, degree, _) = blend_of(&s, &sp);
assert_eq!(degree, 3, "the default is the cubic tangent spline");
let (qa, qb) = (Vec2::new(6.0, 0.0), bctrl[0]);
let l = qa.dist(qb);
let tb = (qb - bctrl[1]).normalized().unwrap();
let want = [qa, qa + Vec2::X * (l / 3.0), qb + tb * (l / 3.0), qb];
for (i, q) in want.iter().enumerate() {
assert!(ctrl[i].dist(*q) < 1e-9, "g1 control {i}: {:?} vs {q:?}", ctrl[i]);
}
assert_eq!(ids(&r["constraints"]).len(), 2, "the two tangents are kept: {r}");
// g2…g4 meet the derivatives by construction and need no constraint of their own.
for order in 2..=4usize {
let mut s = new_sketch();
let (a, b, _) = two_references(&mut s);
let r = run(
&mut s,
"sketch.blend_curve",
json!({
"a": format!("{a}.end"),
"b": format!("{b}.start"),
"continuity": format!("g{order}"),
}),
);
assert!(ids(&r["constraints"]).is_empty(), "g{order}: {r}");
}
}
#[test]
fn blend_curve_between_two_lines_is_curvature_free_to_the_requested_order() {
// Two lines meeting at a right angle: from G2 on, the blend has the lines' own zero
// curvature at both joints (a corner with no curvature jump).
for order in 2..=4usize {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone();
let r = run(
&mut s,
"sketch.blend_curve",
json!({
"a": format!("{a}.end"),
"b": format!("{b}.start"),
"continuity": format!("g{order}"),
}),
);
let sp = ids(&r["curves"])[0].clone();
let (ctrl, degree, piece) = blend_of(&s, &sp);
assert_eq!(degree, 2 * order + 1);
let got0 = blend_derivatives(&ctrl, false, 4);
let got1 = blend_derivatives(&ctrl, true, 4);
assert!(got0[0].dist(Vec2::new(2.0, 0.0)) < 1e-12, "g{order} leaves `a` along +x: {:?}", got0[0]);
assert!(got1[0].dist(Vec2::new(0.0, 2.0)) < 1e-12, "g{order} enters `b` along +y: {:?}", got1[0]);
for j in 1..order {
assert!(got0[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1);
assert!(got1[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1);
}
if order < 4 {
assert!(got0[order].len() > 1e-6, "g{order}: the blend curves, the line does not");
}
assert!(piece.start().dist(Vec2::new(2.0, 0.0)) < 1e-12 && piece.end().dist(Vec2::new(20.0, 10.0)) < 1e-12);
}
}
#[test]
fn blend_curve_matches_a_conic_reference() {
for order in 2..=4usize {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.conic", json!({"start": [20, 10], "apex": [26, 16], "end": [30, 10], "rho": 0.7}))["curves"])[0].clone();
let r = run(
&mut s,
"sketch.blend_curve",
json!({
"a": format!("{a}.end"),
"b": format!("{b}.start"),
"continuity": format!("g{order}"),
}),
);
let sp = ids(&r["curves"])[0].clone();
let (ctrl, degree, _) = blend_of(&s, &sp);
assert_eq!(degree, 2 * order + 1);
// The conic's own derivatives at its start: `w` comes from the sketch's `rho`, and the
// series division in `piece_derivatives` is the expectation.
let w = 0.7 / (1.0 - 0.7);
let conic = Seg2::Conic { a: Vec2::new(20.0, 10.0), apex: Vec2::new(26.0, 16.0), b: Vec2::new(30.0, 10.0), w };
let want = piece_derivatives(&conic, false, order);
let got = blend_derivatives(&ctrl, true, order);
for j in 0..order {
assert!(got[j].dist(want[j]) < 1e-8 * want[j].len().max(1.0), "g{order} order {}: {:?} vs {:?}", j + 1, got[j], want[j]);
}
}
}
#[test]
fn blend_curve_refuses_hostile_orders_and_degenerate_references() {
let mut s = new_sketch();
let (a, b, _) = two_references(&mut s);
let ends = json!({"a": format!("{a}.end"), "b": format!("{b}.start")});
for (order, want) in [("g0", true), ("g9", true), ("G9", true), ("curvature", true), ("", false), ("g2", false), ("G4", false)] {
let mut p = ends.clone();
p["continuity"] = json!(order);
let r = s.execute("sketch.blend_curve", &p);
assert_eq!(r.is_err(), want, "continuity {order:?}: {r:?}");
assert!(r.is_ok() || r.unwrap_err().to_string().contains("continuity"), "continuity {order:?} needs a clear error");
}
let mut numbered = ends.clone();
numbered["continuity"] = json!(3);
assert!(s.execute("sketch.blend_curve", &numbered).is_err(), "a number is not a continuity");
// A point that is not an end of a curve, and the same end twice.
let p = ids(&run(&mut s, "sketch.point", json!({"point": [50, 50]}))["created"]["points"])[0].clone();
let e = s.execute("sketch.blend_curve", &json!({"a": p, "b": format!("{b}.start")})).unwrap_err().to_string();
assert!(e.contains("no curve ends at `a`"), "{e}");
let e = s.execute("sketch.blend_curve", &json!({"a": format!("{a}.end"), "b": format!("{a}.end")})).unwrap_err().to_string();
assert!(e.contains("ends must differ"), "{e}");
// The helper behind the command refuses a corrupt (non-finite) reference and a reference
// with no tangent at all, never guessing at them.
let mut broken = solidcraft_sketch::Sketch::new();
let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap();
let p1 = broken.add_point(Vec2::ZERO, None).unwrap();
let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false }, None).unwrap();
broken.points[p0].pos = Vec2::new(f64::NAN, 4.0);
assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry");
let mut flat = solidcraft_sketch::Sketch::new();
let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap();
let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap();
let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false }, None).unwrap();
let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string();
assert!(e.contains("no tangent"), "{e}");
assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4");
}
#[test]
fn blend_curve_with_g4_closes_a_profile_that_measures_exactly() {
// A quarter-turn blend from (2, 0) into a line at (20, 10), closed by the chord between its
// ends: the region's area comes from the degree-9 curve, not from its chord.
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone();
let r = run(
&mut s,
"sketch.blend_curve",
json!({
"a": format!("{a}.end"),
"b": format!("{b}.start"),
"continuity": "g4",
}),
);
let sp = ids(&r["curves"])[0].clone();
let (ctrl, degree, piece) = blend_of(&s, &sp);
assert_eq!(degree, 9, "G4 is one degree-9 Bézier");
assert_eq!(ctrl.len(), 10);
// The command still reports what it added, with stable ids: ten controls of which the two
// joints are the references' own points, so eight new points, and no new constraint.
assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}");
assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}");
assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}");
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"}));
// Drop the two references: what is left is the blend and its chord, one closed region.
run(&mut s, "sketch.delete", json!({"entities": [a, b]}));
let id = s.active_sketch.unwrap();
let state = s.model.state();
let profiles = &state.sketch(id).unwrap().profiles;
assert_eq!(profiles.len(), 1, "the blend and the chord close one region");
let area = profiles[0].area;
// A fine sampling of the exact piece plus the straight chord reproduces the area; the chord
// alone (what a polyline approximation would give) is exactly zero.
let n = 20_000;
let mut sampled = 0.0;
for i in 0..n {
let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);
sampled += 0.5 * piece.point_at(t0).cross(piece.point_at(t1));
}
// The chord back from (20, 10) to (2, 0) contributes its own shoelace term; together they
// are the region.
sampled += 0.5 * Vec2::new(20.0, 10.0).cross(Vec2::new(2.0, 0.0));
assert!((area - sampled.abs()).abs() < 1e-4, "{area} vs {sampled}");
assert!(area > 1e-2, "the blend curves away from the chord: {area}");
// And the degree-9 profile still extrudes to its area times the height (the kernel's
// volume of a free-form body is a numerical integration, so this is the same 2e-4 the
// neighbouring free-form extrusion test allows).
run(&mut s, "solid.extrude", json!({"distance": 5}));
let m = run(&mut s, "inspect.measure", json!({}));
let volume = m["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN);
assert!((volume - area * 5.0).abs() / (area * 5.0) < 2e-4, "volume {volume} vs {}", area * 5.0);
assert!(m["bodies"][0]["faces"].as_u64().unwrap_or(0) >= 3, "a solid with the blend as a side: {m}");
}
}
+8 -3
View File
@@ -5,6 +5,11 @@ use solidcraft_sketch::{Shape, intersections};
use crate::{Result, params::bad};
/// Degrees the exact extend intersection handles: a round support squares the piece's degree in
/// homogeneous form, and both the root finder and its `evaluate` buffer hold at most 15
/// coefficients, so 2·7 + 1 fits.
const MAX_EXACT_EXTEND_DEGREE: u8 = 7;
fn choose(n: usize, k: usize) -> f64 {
(0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64)
}
@@ -113,7 +118,7 @@ pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result<Ve
let (points, weights): (Vec<Vec2>, Vec<f64>) = match *seg {
Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]),
Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]),
Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]),
Seg2::Bezier { n, p } if (1..=MAX_EXACT_EXTEND_DEGREE).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]),
_ => return Err(bad("sketch.extend", "invalid exact curve piece")),
};
if points.iter().any(|p| !p.is_finite()) {
@@ -195,8 +200,8 @@ mod tests {
elevated.push(y[n - 1]);
y = elevated;
}
let mut points = [Vec2::ZERO; 8];
for (i, p) in points.iter_mut().enumerate() {
let mut points = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL];
for (i, p) in points.iter_mut().take(8).enumerate() {
*p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]);
}
let target = Seg2::Bezier { n: 7, p: points };
+110 -4
View File
@@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
.at("SURFACE", "CREATE")
.icon("surface_loft")
.params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"),
.params("sections: [{sketch, curves?: [curve ids], profiles?} | {body, edges: [[x,y,z]]}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed; a body edge chain takes the face the chain bounds as the surface the end repeats); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g0: how much of the surface at the first and last section the loft repeats — the tangent plane, the curvature, or how that curvature changes); crown?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"),
CommandSpec::new("surface.offset", "Offset Surface", offset)
.at("SURFACE", "CREATE")
.icon("surface_offset")
@@ -369,15 +369,57 @@ fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut sections = Vec::with_capacity(given.len());
for sec in given {
// A section is a chain of sketch curves, or an edge chain of a body whose face the end of
// the loft repeats.
if let Some(body) = str_(sec, "body") {
let edges: Vec<solidcraft_geom::Vec3> =
sec.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, "`sections.edges` must list 1 to 10000 points on the edges of a chain"));
}
if sec.get("sketch").is_some() {
return Err(bad(cmd, "a section is either a sketch chain or a body edge chain"));
}
sections.push(solidcraft_doc::SheetSection {
sketch: SketchId(0),
profiles: None,
curves: Vec::new(),
body: Some(BodyName::from(body.to_string())),
edges: edges.into_iter().map(Position).collect(),
});
continue;
}
let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?;
let curves = string_list(sec, "curves");
if curves.len() > 1000 {
return Err(bad(cmd, "at most 1000 curves a section"));
}
let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None };
sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves });
sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves, body: None, edges: Vec::new() });
}
add_feature(s, p, FeatureKind::SurfaceLoft { sections })
let continuity = match p.get("continuity") {
None => [Continuity::G0, Continuity::G0],
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?;
let word = |x: Option<&Value>| -> Result<Continuity> {
x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3"))
};
[word(list.first())?, word(list.get(1))?]
}
};
let crown = match p.get("crown") {
None | Some(Value::Null) => [0.0, 0.0],
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`crown` must be two numbers, one per end"))?;
let one = |x: Option<&Value>| -> Result<f64> {
x.and_then(Value::as_f64)
.filter(|x| x.is_finite() && (-0.9..=3.0).contains(x))
.ok_or_else(|| bad(cmd, "each `crown` entry must be between -0.9 (flattest) and 3 (fullest)"))
};
[one(list.first())?, one(list.get(1))?]
}
};
add_feature(s, p, FeatureKind::SurfaceLoft { sections, continuity, crown })
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1212,7 +1254,7 @@ mod tests {
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = ["surface.fillet", "inspect.continuity"];
let planned = ["surface.fillet"];
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len();
@@ -1588,4 +1630,68 @@ mod tests {
assert!(s.execute("surface.fair", &json!({"body": "Shell", "iterations": 0, "tolerance": 1.0})).is_err());
assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 0})).is_err());
}
/// A loft through three sections whose first and last are edge chains of two boxes, with
/// `continuity: ["g1","g1"]`: it leaves each box's top face tangentially (the run direction
/// lies in that face) and passes through the sketch between them.
#[test]
fn surface_loft_leaves_a_box_face_tangentially() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "Low"}));
run(&mut s, "solid.box", json!({"corner": [40, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "High"}));
run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 30, "name": "Mid"}));
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "Bump"}));
run(&mut s, "sketch.line", json!({"points": [[0, 14], [20, 14]], "ids": ["b"]}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"surface.loft",
json!({
"sections": [
{"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]},
{"sketch": "Bump", "curves": ["b"]},
{"body": "High", "edges": [[40, 5, 10], [40, 15, 10]]}
],
"continuity": ["g1", "g1"],
"crown": [0, 0],
"body_name": "Blend"
}),
);
let body = s.world_state().body("Blend").unwrap().body.clone();
// A fine grid in the run direction: the seams sit at u = 0, 1/2 and 1, and the first step
// off each one is the tangent there (a thousandth of the run, so it is the tangent).
let grid = solidcraft_kernel::sample_grid(&body, 1001, 9).unwrap();
// The ends lie on the two boxes' top faces, and the sketch is met exactly between them.
for p in &grid[0] {
assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 20.0).abs() < 1e-9, "the first seam is the box edge: {p:?}");
}
for p in &grid[1000] {
assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 40.0).abs() < 1e-9, "the last seam is the box edge: {p:?}");
}
for p in &grid[500] {
assert!((p.z - 14.0).abs() < 1e-9 && (p.x - 30.0).abs() < 1e-9, "the blend meets the sketch: {p:?}");
}
// The seam leaves along the box's top face: the run direction the blend is given is what
// the face holds, so the tangent there is horizontal and along the run. (The step the grid
// takes is a thousandth of the run, and a chord over that step carries an O(h²) bias of
// about 0.011 here, so 1e-2 on the rise off the face is what the grid can measure; the
// kernel's own tests pin the tangent to the frame's leave direction exactly.)
for j in [0usize, 4, 8] {
let d = (grid[1][j] - grid[0][j]).normalized().unwrap();
assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend leaves the low box's top face: {d:?}");
let d = (grid[1000][j] - grid[999][j]).normalized().unwrap();
assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend arrives along the high box's top face: {d:?}");
}
// Hostile parameters are refused, never panicking.
let bad = [
json!({"sections": [{"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1"]}),
json!({"sections": [{"body": "Low", "edges": []}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1", "g1"]}),
json!({"sections": [{"body": "Low", "edges": [[20, 5, 10]], "sketch": "Bump"}, {"sketch": "Bump", "curves": ["b"]}]}),
json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "crown": [5, 0]}),
json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g9", "g1"]}),
];
for p in bad {
assert!(s.execute("surface.loft", &p).is_err(), "hostile loft params: {p}");
}
}
}
+346 -8
View File
@@ -5,8 +5,19 @@ use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_, num, vec3};
use crate::{Result, Session, SurfaceAnalysis};
use crate::params::{bad, bool_, num, str_, vec3};
use crate::{ContinuitySeam, Result, Session, SurfaceAnalysis, kernel};
/// Stations along each edge when the call does not say (`samples`).
const DEFAULT_SAMPLES: usize = 33;
/// Tolerances a seam is judged against when the call does not say: a micron of position gap, a
/// degree of normal angle, and a curvature jump of 1/100 mm.
const DEFAULT_GAP_MM: f64 = 1e-3;
const DEFAULT_ANGLE_DEG: f64 = 1.0;
const DEFAULT_CURVATURE_PER_MM: f64 = 0.01;
/// Most edges one call may pick, and most bodies one call may analyse at once.
const MAX_PICKS: usize = 512;
const MAX_BODIES: usize = 64;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
@@ -34,11 +45,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("curvature_map")
.noundo()
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
planned!("inspect.continuity", "Continuity Analysis", "770")
.at("SURFACE", "INSPECT")
.icon("continuity")
.noundo()
.params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"),
CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params(
"body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \
a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \
gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \
clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \
{value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \
the seams are shaded in the viewport",
),
];
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
@@ -94,12 +108,214 @@ fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
set(s, p, SurfaceAnalysis::Curvature { radius })
}
/// `inspect.continuity`: measure how far the two faces meeting at each picked edge (or at every
/// edge of a body) depart from sharing a curve (G0), a tangent plane (G1) and a curvature (G2),
/// and keep the seams so the viewport can shade them. View state only: the design and its undo
/// history are untouched (`noundo`), as with the other analyses.
fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.continuity";
if bool_(p, "clear").unwrap_or(false) {
s.continuity.clear();
s.revision += 1;
return Ok(json!({ "analysis": "continuity", "cleared": true }));
}
let samples = match p.get("samples") {
None => DEFAULT_SAMPLES,
Some(v) => match v.as_f64().filter(|n| n.is_finite() && n.fract() == 0.0 && (3.0..=257.0).contains(n)) {
Some(n) => n as usize,
None => return Err(bad(cmd, "`samples` must be 3…257 stations along each edge")),
},
};
let tolerances = kernel::ContinuityTolerances {
gap_mm: tolerance(p, "gap_tolerance_mm", DEFAULT_GAP_MM)?,
angle_deg: tolerance(p, "angle_tolerance_deg", DEFAULT_ANGLE_DEG)?,
curvature_per_mm: tolerance(p, "curvature_tolerance_per_mm", DEFAULT_CURVATURE_PER_MM)?,
};
let picks = edge_picks(p)?;
let st = s.world_state();
let named = str_(p, "body");
let candidates: Vec<&solidcraft_doc::ModelBody> = match named {
Some(name) => vec![st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?],
None => st.bodies.iter().filter(|b| s.body_visible(&b.name)).collect(),
};
if candidates.is_empty() {
return Err(bad(cmd, "there are no visible bodies to analyse: name a `body`"));
}
if candidates.len() > MAX_BODIES {
return Err(bad(cmd, format!("more than {MAX_BODIES} bodies are shown: name the `body` to analyse")));
}
let mut targets: Vec<(&solidcraft_doc::ModelBody, Vec<usize>)> = Vec::new();
if picks.is_empty() {
targets.extend(candidates.into_iter().map(|b| (b, Vec::new())));
} else {
// Picks mean edges of one body (a design may stack bodies at the same place): a list
// that lands on one body's edges only is analysed, and anything else is refused.
let mut hits = Vec::new();
let mut refused = None;
for b in candidates {
match kernel::pick_edges(&b.body, &picks) {
Ok(edges) => hits.push((b, edges)),
Err(e) => refused = refused.or(Some(e)),
}
}
if hits.is_empty() {
return Err(match (named, refused) {
(Some(_), Some(e)) => e.into(),
_ => bad(cmd, "no visible body has an edge matching `edges`"),
});
}
if hits.len() > 1 {
return Err(bad(cmd, "`edges` matches edges of more than one body: name the `body` to analyse"));
}
targets.extend(hits);
}
let mut seams: Vec<ContinuitySeam> = Vec::new();
let mut bodies = Vec::new();
let mut summary = Summary::default();
for (body, edges) in &targets {
let report = kernel::seam_diagnostics(&body.body, edges, samples, tolerances)?;
let mut rows = Vec::new();
for seam in &report.seams {
summary.count(seam.verdict);
seams.push(ContinuitySeam { points: seam.points.clone(), verdict: seam.verdict });
rows.push(json!({
"edge": seam.edge,
"length_mm": seam.length,
"samples": seam.samples,
"gap_mm": seam.gap_mm,
"normal_angle_deg": seam.normal_angle_deg,
"curvature_jump_per_mm": seam.curvature_jump_per_mm,
"verdict": seam.verdict,
}));
}
summary.skipped += report.skipped;
summary.unmeasured += report.unmeasured;
bodies.push(json!({
"body": body.name,
"edges": rows,
"skipped": report.skipped,
"unmeasured": report.unmeasured,
}));
}
s.continuity = seams;
s.revision += 1;
Ok(json!({
"analysis": "continuity",
"samples": samples,
"tolerances": {
"gap_mm": tolerances.gap_mm,
"angle_deg": tolerances.angle_deg,
"curvature_per_mm": tolerances.curvature_per_mm,
},
"bodies": bodies,
"summary": {
"edges": summary.edges,
"smooth": summary.smooth,
"curvature_jump": summary.curvature_jump,
"sharp": summary.sharp,
"gap": summary.gap,
"skipped": summary.skipped,
"unmeasured": summary.unmeasured,
"worst": summary.worst(),
},
}))
}
/// A tolerance parameter. A value that is given but unusable (NaN, infinity, negative, a
/// string, a bare `null`) is refused: `num` would fold it into "not given".
fn tolerance(p: &Value, key: &str, default: f64) -> Result<f64> {
match p.get(key) {
None => Ok(default),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (0.0..1e9).contains(&x) => Ok(x),
_ => Err(bad("inspect.continuity", format!("`{key}` must be a finite number, 0 or more"))),
},
}
}
/// The `edges` parameter: points on the edges to measure, or `{"index": i}` entries in the
/// display mesh's edge order. Absent (or an empty list) means every edge of the body.
fn edge_picks(p: &Value) -> Result<Vec<kernel::EdgePick>> {
let Some(value) = p.get("edges") else { return Ok(Vec::new()) };
let Some(list) = value.as_array() else {
return Err(bad("inspect.continuity", "`edges` must be a list of [x,y,z] points or {\"index\": i} entries"));
};
if list.len() > MAX_PICKS {
return Err(bad("inspect.continuity", format!("pick at most {MAX_PICKS} edges")));
}
let mut out = Vec::new();
for item in list {
if let Some(index) = item.get("index").and_then(Value::as_u64) {
let index = usize::try_from(index).map_err(|_| bad("inspect.continuity", "`index` is too large"))?;
out.push(kernel::EdgePick::Index(index));
} else if let Some(point) = vec3(item) {
out.push(kernel::EdgePick::Point(point));
} else {
return Err(bad("inspect.continuity", "each `edges` entry is a point [x,y,z] or {\"index\": i}"));
}
}
Ok(out)
}
/// Counts of a continuity reply, and the worst verdict over every edge measured.
#[derive(Clone, Copy, Debug, Default)]
struct Summary {
edges: usize,
smooth: usize,
curvature_jump: usize,
sharp: usize,
gap: usize,
skipped: usize,
unmeasured: usize,
}
impl Summary {
fn count(&mut self, verdict: kernel::SeamVerdict) {
self.edges += 1;
match verdict {
kernel::SeamVerdict::Smooth => self.smooth += 1,
kernel::SeamVerdict::CurvatureJump => self.curvature_jump += 1,
kernel::SeamVerdict::Sharp => self.sharp += 1,
kernel::SeamVerdict::Gap => self.gap += 1,
}
}
/// The worst verdict over the edges measured (`None` when there was nothing to measure).
/// [`kernel::SeamVerdict`] runs from the best continuity to the worst.
fn worst(&self) -> Option<kernel::SeamVerdict> {
[
(self.smooth, kernel::SeamVerdict::Smooth),
(self.curvature_jump, kernel::SeamVerdict::CurvatureJump),
(self.sharp, kernel::SeamVerdict::Sharp),
(self.gap, kernel::SeamVerdict::Gap),
]
.into_iter()
.filter(|(count, _)| *count > 0)
.map(|(_, verdict)| verdict)
.max()
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{Value, json};
use crate::{Session, SurfaceAnalysis};
fn boxed(s: &mut Session, size: f64) {
s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap();
}
/// The edges of the first body of a continuity reply.
fn edges(v: &Value) -> &Vec<Value> {
v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}"))
}
/// The seams that read as tangent (within 0.01° of the neighbouring face).
fn tangent(edges: &[Value]) -> Vec<&Value> {
edges.iter().filter(|e| e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN) < 0.01).collect()
}
#[test]
fn surface_analyses_are_view_state() {
let mut s = Session::default();
@@ -121,4 +337,126 @@ mod tests {
assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err());
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err());
}
/// A box edge: the faces meet exactly (G0) and square to each other (G1), so the analysis
/// must read 0 gap and 90°, and must keep its seams as view state without an undo step.
#[test]
fn continuity_reports_a_box_edge_as_a_gap_free_right_angle() {
let mut s = Session::default();
boxed(&mut s, 20.0);
let undo = s.undo.len();
let v = s.execute("inspect.continuity", &json!({})).unwrap();
assert_eq!(edges(&v).len(), 12);
for e in edges(&v) {
let gap = e["gap_mm"]["value"].as_f64().unwrap();
let angle = e["normal_angle_deg"]["value"].as_f64().unwrap();
let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap();
assert!(gap < 1e-6, "gap {gap} in {e}");
assert!((angle - 90.0).abs() < 1e-9, "angle {angle} in {e}");
assert!(jump < 1e-9, "curvature jump {jump} in {e}");
assert_eq!(e["verdict"], json!("sharp"), "{e}");
assert_eq!(e["samples"], json!(33), "{e}");
assert!((e["length_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{e}");
assert_eq!(e["normal_angle_deg"]["at"].as_array().map(Vec::len), Some(3), "{e}");
let f = e["normal_angle_deg"]["edge_fraction"].as_f64().unwrap();
assert!((0.0..=1.0).contains(&f), "{e}");
}
assert_eq!(v["analysis"], json!("continuity"));
assert_eq!(v["samples"], json!(33));
assert_eq!(v["tolerances"], json!({"gap_mm": 1e-3, "angle_deg": 1.0, "curvature_per_mm": 0.01}));
assert_eq!(v["summary"]["edges"], json!(12));
assert_eq!(v["summary"]["sharp"], json!(12));
assert_eq!(v["summary"]["worst"], json!("sharp"));
assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport");
assert_eq!(s.undo.len(), undo, "no undo step");
assert_eq!(s.analysis, None, "the other analyses are left alone");
}
/// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its
/// two seams read ~0° with the fillet's 1 / radius curvature jump against the planes.
#[test]
fn continuity_finds_the_fillet_seams_tangent_and_their_curvature_jump() {
let mut s = Session::default();
boxed(&mut s, 20.0);
s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap();
let v = s.execute("inspect.continuity", &json!({})).unwrap();
assert_eq!(edges(&v).len(), 15, "the rounded edge leaves three new edges behind: {v}");
let seams = tangent(edges(&v));
assert_eq!(seams.len(), 2, "the fillet meets its two neighbours tangentially: {v}");
for e in seams {
assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}");
let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap();
assert!((jump - 0.25).abs() < 1e-6, "1 / radius 4: {e}");
assert_eq!(e["verdict"], json!("curvature_jump"), "{e}");
}
assert_eq!(v["summary"]["curvature_jump"], json!(2));
assert_eq!(v["summary"]["sharp"], json!(13));
assert_eq!(v["summary"]["smooth"], json!(0));
}
/// A slot's straight wall runs tangentially into its arc wall: the join is G1 but its
/// curvature jumps by the arc's 1 / radius, which is exactly what a Class-A check looks for.
#[test]
fn continuity_reports_one_over_the_radius_where_a_line_meets_an_arc() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.slot.center_point", &json!({"center": [0, 0], "end": [20, 0], "width": 10})).unwrap();
s.execute("solid.extrude", &json!({"distance": 5})).unwrap();
let v = s.execute("inspect.continuity", &json!({"angle_tolerance_deg": 0.01})).unwrap();
let seams = tangent(edges(&v));
assert_eq!(seams.len(), 4, "a slot has four line-to-arc joins: {v}");
for e in seams {
assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}");
let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap();
assert!((jump - 0.2).abs() < 1e-6, "1 / radius 5: {e}");
assert_eq!(e["verdict"], json!("curvature_jump"), "{e}");
}
assert_eq!(v["summary"]["curvature_jump"], json!(4));
}
/// `clear` takes the shading away, and every continuity call stays view state.
#[test]
fn continuity_is_view_state_and_clears() {
let mut s = Session::default();
boxed(&mut s, 10.0);
let undo = s.undo.len();
s.execute("inspect.continuity", &json!({"samples": 5})).unwrap();
assert_eq!(s.continuity.len(), 12);
assert_eq!(s.undo.len(), undo, "no undo step");
let cleared = s.execute("inspect.continuity", &json!({"clear": true})).unwrap();
assert_eq!(cleared["cleared"], json!(true));
assert!(s.continuity.is_empty(), "clear leaves nothing shaded");
assert_eq!(s.undo.len(), undo);
}
/// Hostile parameters are refused with an error, never a panic.
#[test]
fn hostile_continuity_params_are_refused() {
let mut s = Session::default();
boxed(&mut s, 10.0);
for params in [
json!({"samples": 0}),
json!({"samples": 2}),
json!({"samples": 258}),
json!({"samples": 3.5}),
json!({"samples": "33"}),
json!({"samples": null}),
json!({"gap_tolerance_mm": -1}),
json!({"gap_tolerance_mm": 1e12}),
json!({"angle_tolerance_deg": f64::NAN}),
json!({"curvature_tolerance_per_mm": "tight"}),
json!({"edges": 3}),
json!({"edges": [[1, 2]]}),
json!({"edges": [[]]}),
json!({"edges": [{"index": 99999}]}),
json!({"edges": [[500, 500, 500]]}),
json!({"body": "Nope"}),
] {
assert!(s.execute("inspect.continuity", &params).is_err(), "accepted {params}");
}
let many: Vec<Value> = (0..513).map(|i| json!([i as f64, 0.0, 0.0])).collect();
assert!(s.execute("inspect.continuity", &json!({"edges": many})).is_err(), "513 picks");
assert!(s.continuity.is_empty(), "a refused call shades nothing");
assert_eq!(s.undo.len(), 1, "only the box is an undo step");
}
}
+15
View File
@@ -239,6 +239,9 @@ pub struct Session {
pub section: Option<(Vec3, Vec3)>,
/// Surface analysis shading the model (zebra, draft, curvature map).
pub analysis: Option<SurfaceAnalysis>,
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
/// part of the design and not an undo step.
pub continuity: Vec<ContinuitySeam>,
/// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an
/// undo step. A design brings its saved visibility when it opens (`Session::new`).
pub visibility: Visibility,
@@ -301,6 +304,16 @@ pub enum SurfaceAnalysis {
Access { dir: Vec3 },
}
/// One seam measured by `inspect.continuity`, where the viewport shades it: the stations it was
/// sampled at, in world coordinates, and how it reads. View state only: the design is untouched.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct ContinuitySeam {
/// Sample points along the seam, in world coordinates.
pub points: Vec<Vec3>,
/// The seam's worst verdict along its length.
pub verdict: kernel::SeamVerdict,
}
impl SurfaceAnalysis {
/// How a picture shades the faces for this analysis.
pub fn mode(self) -> render::AnalysisMode {
@@ -356,6 +369,7 @@ impl Session {
sketch_clipboard: None,
section: None,
analysis: None,
continuity: Vec::new(),
checkpoints: Vec::new(),
next_checkpoint: 1,
saved_visibility: visibility.clone(),
@@ -823,6 +837,7 @@ impl Session {
sketch_clipboard: self.sketch_clipboard.clone(),
section: None,
analysis: None,
continuity: Vec::new(),
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: self.visibility.clone(),