Curvature comb: exact teeth on model edges and faces

`inspect.curvature_comb` read the display mesh for model geometry: an
edge's comb fitted a circle through every three points of the nearest
mesh polyline, a face's did the same on its iso lines' chord points. The
comb was jagged, changed with the mesh tolerance and ignored `density`.

- geom: `CurveEval::jet`, a curve's point, unit tangent and curvature
  vector kN from its first two derivatives (lines and conics in closed
  form, B-splines and NURBS from the basis functions' derivatives, a
  traced intersection from both surfaces' normal curvatures along its
  tangent, by Meusnier: kN lies in the plane of the two normals and its
  component along each is that surface's normal curvature).
- kernel (curvature_comb.rs): the edge nearest a pick by the exact
  distance to its curve (a periodic face's seam and poles left out),
  `density` teeth at even steps of the edge's parameter; along an iso
  line, the surface's normal curvature n.S_tt / |S_t|^2 along its normal.
  `IsoLine` carries the running surface parameter's span.
- engine: edges and faces use them (and `inspect.minimum_radius` on
  edges); new optional `length` multiplies the automatic scale. The reply
  keeps its shape.

Tests: a circle edge reads 1/R to 1e-9 at Coarse and Ultra display
quality alike, with `density` teeth; an extruded ellipse's edge matches
ab/(b^2 cos^2 t + a^2 sin^2 t)^(3/2) at every tooth, a/b^2 and b/a^2 at
the vertices; a cylinder face's rings read 1/R and its lines 0; `scale`
and `length` set the teeth's length; curve jets on every record kind.
The existing cylinder-edge check tightens from 0.5 mm to 1e-9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 06:51:09 -04:00
committed by Irakli Kourtis
parent 172c64cef8
commit 9142c6c5b5
6 changed files with 500 additions and 57 deletions
+172 -49
View File
@@ -13,7 +13,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "INSPECT")
.icon("curvature_comb")
.noundo()
.params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges] | faces?: [[x,y,z] points on faces] with count?: isoparametric curves each way (default 4; combs along the face's iso grid); density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"),
.params(
"curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges: the edge whose \
exact curve passes nearest] | faces?: [[x,y,z] points on faces] with count?: isoparametric curves each way (default 4; \
combs along the face's iso grid); density?: teeth per curve, 2…2000 (default 40; on an edge or an iso curve exactly \
that many, evenly spaced along its parameter); scale?: comb length per unit curvature (default auto: the longest \
tooth a fifth of the combs' size); length?: a multiplier on the automatic scale, 0.01…100 (default 1; ignored with \
`scale`). The curvature is exact: from each sketch curve, edge curve (line, circle, ellipse, B-spline, NURBS, \
intersection curve) or face surface's own derivatives, never from the display mesh; a face's teeth are the \
surface's normal curvature along each iso curve, along the surface normal. → combs: [{curve, teeth: [[foot, tip]], \
envelope: [tip], max_curvature, min_radius}], scale",
),
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
.icon("min_radius")
@@ -61,25 +71,45 @@ fn teeth_2d(segs: &[Seg2], density: usize) -> Vec<(Vec2, f64, Vec2)> {
out
}
/// Teeth along a 3D polyline (discrete curvature from three consecutive points).
fn teeth_3d(pts: &[Vec3]) -> Vec<(Vec3, f64, Vec3)> {
let mut out = Vec::new();
for w in pts.windows(3) {
let (a, b, c) = (w[0], w[1], w[2]);
let (ab, bc, ca) = (b - a, c - b, a - c);
let area2 = ab.cross(bc).len();
let den = ab.len() * bc.len() * ca.len();
let k = if den > 1e-300 { 2.0 * area2 / den } else { 0.0 };
// Towards the centre of curvature.
let n = (a + c - b * 2.0).normalized().unwrap_or(Vec3::ZERO);
out.push((b, k, n));
/// Teeth from the kernel's exact comb: (foot, curvature, unit normal towards the centre of
/// curvature; zero where straight).
fn teeth_exact(teeth: &[crate::kernel::CombTooth]) -> Vec<(Vec3, f64, Vec3)> {
teeth
.iter()
.map(|t| {
let k = t.curvature.len();
(t.at, k, if k > 0.0 { t.curvature * (1.0 / k) } else { Vec3::ZERO })
})
.collect()
}
/// The comb of the body edge whose exact curve passes nearest `at`, over the model's B-rep bodies
/// (mesh bodies have no curves): `density` exact teeth along it.
fn edge_teeth(st: &solidcraft_doc::ModelState, at: Vec3, density: usize) -> Result<Option<Vec<(Vec3, f64, Vec3)>>> {
let mut best: Option<(f64, &solidcraft_doc::ModelBody, usize)> = None;
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
if let Some((d, edge)) = crate::kernel::comb_edge_near(&b.body, at)?
&& best.is_none_or(|x| d < x.0)
{
best = Some((d, b, edge));
}
}
match best {
Some((_, b, edge)) => Ok(Some(teeth_exact(&crate::kernel::edge_comb(&b.body, edge, density)?))),
None => Ok(None),
}
out
}
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.curvature_comb";
let density = num(p, "density").map(|d| d.clamp(2.0, 2000.0) as usize).unwrap_or(40);
let density = num(p, "density")
.filter(|d| d.is_finite())
.map(|d| d.clamp(crate::kernel::MIN_COMB_TEETH as f64, crate::kernel::MAX_COMB_TEETH as f64) as usize)
.unwrap_or(40);
let length = match p.get("length") {
None | Some(Value::Null) => 1.0,
Some(v) => v.as_f64().filter(|x| (0.01..=100.0).contains(x)).ok_or_else(|| bad(cmd, "`length` must be a multiplier 0.01…100"))?,
};
let st = s.model.state();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
@@ -101,21 +131,8 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(edges) = p.get("edges").and_then(Value::as_array) {
for e in edges.iter().take(1000) {
let at = vec3(e).ok_or_else(|| bad(cmd, "`edges` must be points [x,y,z]"))?;
let mut best: Option<(f64, Vec<Vec3>)> = None;
for b in &st.bodies {
let m = b.mesh();
for (i, pts) in m.edges.iter().enumerate() {
if m.seams.get(i).copied().unwrap_or(false) {
continue;
}
let d = pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
if best.as_ref().is_none_or(|x| d < x.0) {
best = Some((d, pts.clone()));
}
}
}
let (_, pts) = best.ok_or_else(|| bad(cmd, "no edge there"))?;
combs.push((format!("edge@{},{},{}", at.x, at.y, at.z), teeth_3d(&pts)));
let teeth = edge_teeth(&st, at, density)?.ok_or_else(|| bad(cmd, "no edge there"))?;
combs.push((format!("edge@{},{},{}", at.x, at.y, at.z), teeth));
}
}
if let Some(faces) = p.get("faces") {
@@ -131,7 +148,8 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
let (body, face) = iso_face_at(s, &world, at).ok_or_else(|| bad(cmd, "no face there"))?;
for (k, line) in body.iso_lines(face, count)?.iter().enumerate() {
combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_3d(&line.points)));
let teeth = teeth_exact(&crate::kernel::iso_comb(&body.body, face, line, density)?);
combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth));
}
}
}
@@ -142,8 +160,8 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let kmax = combs.iter().flat_map(|c| c.1.iter().map(|t| t.1.abs())).fold(0.0_f64, f64::max);
let bounds: Aabb3 = combs.iter().flat_map(|c| c.1.iter().map(|t| t.0)).collect();
let diag = bounds.diagonal();
let scale =
num(p, "scale").filter(|x| *x > 0.0).unwrap_or(if kmax > 1e-12 && !bounds.is_empty() && diag.is_finite() { 0.2 * diag / kmax } else { 1.0 });
let auto = if kmax > 1e-12 && !bounds.is_empty() && diag.is_finite() { 0.2 * diag / kmax } else { 1.0 };
let scale = num(p, "scale").filter(|x| x.is_finite() && *x > 0.0).unwrap_or(auto * length);
let out: Vec<Value> = combs
.iter()
.map(|(id, teeth)| {
@@ -253,21 +271,8 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(edges) = p.get("edges").and_then(Value::as_array) {
for e in edges.iter().take(1000) {
let at = vec3(e).ok_or_else(|| bad(cmd, "`edges` must be points [x,y,z]"))?;
let mut best: Option<(f64, Vec<Vec3>)> = None;
for b in &st.bodies {
let m = b.mesh();
for (i, pts) in m.edges.iter().enumerate() {
if m.seams.get(i).copied().unwrap_or(false) {
continue;
}
let d = pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
if best.as_ref().is_none_or(|x| d < x.0) {
best = Some((d, pts.clone()));
}
}
}
let (_, pts) = best.ok_or_else(|| bad(cmd, "no edge there"))?;
items.push((format!("edge@{},{},{}", at.x, at.y, at.z), min_of(&teeth_3d(&pts))));
let teeth = edge_teeth(&st, at, 400)?.ok_or_else(|| bad(cmd, "no edge there"))?;
items.push((format!("edge@{},{},{}", at.x, at.y, at.z), min_of(&teeth)));
}
}
let faces: Vec<Vec3> = match p.get("faces").and_then(Value::as_array) {
@@ -457,7 +462,7 @@ mod tests {
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap();
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 1e-9, "{r}");
assert!(s.execute("inspect.curvature_comb", &json!({})).is_err());
// Combs along the side face's isocurves: rings of radius 8 and straight lines along it.
let r = s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": 3})).unwrap();
@@ -470,6 +475,124 @@ mod tests {
assert!(s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": "x"})).is_err());
}
/// Each tooth of a comb reply as (foot, curvature): the tooth's length over the scale.
fn tooth_curvatures(r: &Value, comb: usize) -> Vec<([f64; 3], f64)> {
let scale = r["scale"].as_f64().unwrap();
r["combs"][comb]["teeth"]
.as_array()
.unwrap()
.iter()
.map(|t| {
let [a, b] = [0, 1].map(|i| [0, 1, 2].map(|k| t[i][k].as_f64().unwrap()));
(a, (0..3).map(|k| (b[k] - a[k]).powi(2)).sum::<f64>().sqrt() / scale)
})
.collect()
}
/// An edge's comb is its exact curve's: on a cylinder's circular edge of radius R every tooth
/// reads 1/R to 1e-9, at any display quality (the comb never reads the mesh), the teeth stand
/// out of the circle (away from its centre), and `density` is the number of teeth.
#[test]
fn an_edge_comb_is_exact_and_has_density_teeth() {
use solidcraft_doc::display_quality::{DisplayQuality, set_current};
let mut s = Session::default();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 0.75, "height": 40})).unwrap();
let mut feet = Vec::new();
for quality in [DisplayQuality::Coarse, DisplayQuality::Ultra] {
set_current(quality);
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[50.75, 0, 40]], "density": 7})).unwrap();
let teeth = tooth_curvatures(&r, 0);
assert_eq!(teeth.len(), 7, "{r}");
for (foot, k) in &teeth {
assert!((k - 1.0 / 0.75).abs() < 1e-9, "{k} at {foot:?}");
assert!(((foot[0] - 50.0).hypot(foot[1]) - 0.75).abs() < 1e-9 && (foot[2] - 40.0).abs() < 1e-9, "{foot:?}");
}
let tip = &r["combs"][0]["envelope"][2];
assert!((tip[0].as_f64().unwrap() - 50.0).hypot(tip[1].as_f64().unwrap()) > 0.75, "outside the circle");
assert!((r["combs"][0]["max_curvature"].as_f64().unwrap() - 1.0 / 0.75).abs() < 1e-9);
feet.push(r["combs"][0]["teeth"].clone());
}
set_current(DisplayQuality::Normal);
assert_eq!(feet[0], feet[1], "the same comb at any display quality");
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[50.75, 0, 0]], "density": 123})).unwrap();
assert_eq!(r["combs"][0]["teeth"].as_array().unwrap().len(), 123);
for density in [json!("x"), json!(-5), json!(1e300)] {
assert!(s.execute("inspect.curvature_comb", &json!({"edges": [[50.75, 0, 0]], "density": density})).is_ok(), "{density}: clamped");
}
}
/// An ellipse's curvature, a b / (b² cos² t + a² sin² t)^{3/2} at angle t, is a/b² at the ends of
/// its major axis and b/a² at those of its minor one: the comb of an extruded ellipse's edge
/// reads it at every tooth, the ends included.
#[test]
fn an_ellipse_edge_comb_matches_the_closed_form() {
let (a, b) = (10.0_f64, 4.0_f64);
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.ellipse", &json!({"center": [0, 0], "major": [a, 0], "minor_radius": b})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance": 5})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[0, b, 5]], "density": 40})).unwrap();
let exact = |p: [f64; 3]| {
let (c, sn) = (p[0] / a, p[1] / b);
a * b / (b * b * c * c + a * a * sn * sn).powf(1.5)
};
let teeth = tooth_curvatures(&r, 0);
assert!(teeth.len() >= 20, "{r}");
for (foot, k) in &teeth {
assert!(((foot[0] / a).hypot(foot[1] / b) - 1.0).abs() < 1e-9, "on the ellipse: {foot:?}");
assert!((k - exact(*foot)).abs() < 1e-9 * exact(*foot), "{k} vs {} at {foot:?}", exact(*foot));
}
// The vertices: the teeth at the ends of the axes, wherever the edge's parameter puts them.
let at = |q: [f64; 2]| teeth.iter().find(|(f, _)| (f[0] - q[0]).hypot(f[1] - q[1]) < 1e-9).map(|t| t.1);
let major = [[a, 0.0], [-a, 0.0]].into_iter().filter_map(at).collect::<Vec<_>>();
let minor = [[0.0, b], [0.0, -b]].into_iter().filter_map(at).collect::<Vec<_>>();
assert!(!major.is_empty() && !minor.is_empty(), "teeth at the vertices: {teeth:?}");
assert!(major.iter().all(|k| (k - a / (b * b)).abs() < 1e-9), "{major:?}");
assert!(minor.iter().all(|k| (k - b / (a * a)).abs() < 1e-12), "{minor:?}");
assert!((r["combs"][0]["max_curvature"].as_f64().unwrap() - a / (b * b)).abs() < 1e-9, "{r}");
}
/// A face comb follows the surface: on a cylinder's side the iso curves round it read 1/R at
/// every tooth and those along it 0, the teeth along the surface normal; `scale` sets the teeth's
/// length per unit curvature and `length` multiplies the automatic one.
#[test]
fn a_face_comb_is_the_normal_curvature_and_scale_sets_its_length() {
let mut s = Session::default();
s.execute("solid.cylinder", &json!({"radius": 6, "height": 30})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"faces": [[6, 0, 15]], "count": 3, "density": 9})).unwrap();
let combs = r["combs"].as_array().unwrap();
let (mut rings, mut lines) = (0, 0);
for (i, comb) in combs.iter().enumerate() {
let teeth = tooth_curvatures(&r, i);
assert_eq!(teeth.len(), 9, "{comb}");
if teeth.iter().all(|(_, k)| *k == 0.0) {
lines += 1;
assert!(comb["min_radius"].is_null());
} else {
rings += 1;
assert!(teeth.iter().all(|(_, k)| (k - 1.0 / 6.0).abs() < 1e-9), "{teeth:?}");
// Out of the surface, along its normal (radially, at the tooth's height).
for t in comb["teeth"].as_array().unwrap() {
let q = |i: usize, k: usize| t[i][k].as_f64().unwrap();
assert!((q(1, 2) - q(0, 2)).abs() < 1e-9 && q(1, 0).hypot(q(1, 1)) > 6.0 + 1e-6, "{t}");
}
}
}
assert_eq!((rings, lines), (3, 3), "{r}");
// Tooth length is curvature × scale.
let tooth = |r: &Value| tooth_curvatures(r, 0)[0].1 * r["scale"].as_f64().unwrap();
let auto = s.execute("inspect.curvature_comb", &json!({"faces": [[6, 0, 15]], "count": 3})).unwrap();
let fixed = s.execute("inspect.curvature_comb", &json!({"faces": [[6, 0, 15]], "count": 3, "scale": 12})).unwrap();
assert!((tooth(&fixed) - 2.0).abs() < 1e-9, "12 × 1/6: {}", tooth(&fixed));
let longer = s.execute("inspect.curvature_comb", &json!({"faces": [[6, 0, 15]], "count": 3, "length": 3})).unwrap();
assert!((longer["scale"].as_f64().unwrap() - 3.0 * auto["scale"].as_f64().unwrap()).abs() < 1e-9);
assert!((tooth(&longer) - 3.0 * tooth(&auto)).abs() < 1e-9);
for bad in [json!(0), json!(-1), json!(1000), json!("2")] {
assert!(s.execute("inspect.curvature_comb", &json!({"faces": [[6, 0, 15]], "length": bad})).is_err(), "{bad}");
}
}
#[test]
fn minimum_radius_and_center_of_mass() {
let mut s = Session::default();
+91
View File
@@ -8,6 +8,7 @@
//!
//! Call on validated records. Evaluation of a well-formed record never panics: a point that
//! cannot be had (a vanishing weight, a Newton step that does not converge) is `None`.
use super::normal_section::normal_section;
use super::pcurve::Error;
use super::{Curve, Curve2, Frame, Surface};
use crate::Vec3;
@@ -338,6 +339,23 @@ pub enum CurveEval {
Intersection(Box<Intersection>),
}
/// A curve's differential geometry at one parameter.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CurveJet {
pub point: Vec3,
/// The unit tangent, along the curve's parameter.
pub tangent: Vec3,
/// The curvature vector `κ N`, 1/mm: towards the centre of curvature, its length the
/// curvature; zero where the curve is straight.
pub curvature: Vec3,
}
impl CurveJet {
fn is_finite(&self) -> bool {
self.point.is_finite() && self.tangent.is_finite() && self.curvature.is_finite()
}
}
/// A `surface_intersection` ready to evaluate.
#[derive(Clone, Debug, PartialEq)]
pub struct Intersection {
@@ -402,6 +420,44 @@ impl CurveEval {
}
}
/// The point, unit tangent and curvature vector at `t` ([`CurveJet`]), exactly: from the
/// record's first two derivatives on a line, conic or spline (in closed form, or the basis
/// functions' own, weights divided out), straight on each segment of a polyline; on a traced
/// intersection from both surfaces' normal curvatures along its tangent
/// ([`Intersection::jet`]). `None` where the tangent vanishes, the two surfaces are tangent,
/// or a value is not finite.
pub fn jet(&self, t: f64) -> Option<CurveJet> {
if !t.is_finite() {
return None;
}
let (point, d1, d2) = match self {
CurveEval::Line { start, end } => (*start + (*end - *start) * t, *end - *start, Vec3::ZERO),
CurveEval::Conic { axes: a, radii } => {
let (s, c) = t.sin_cos();
let (x, y) = (a.x * (radii[0] * c), a.y * (radii[1] * s));
(a.o + x + y, a.y * (radii[1] * c) - a.x * (radii[0] * s), -(x + y))
}
CurveEval::Polyline(_) => {
let (p, d) = self.leader(t)?;
(p, d, Vec3::ZERO)
}
CurveEval::Spline(c) => match c.derivatives(t, 2)?.as_slice() {
[p, d1, d2] => (*p, *d1, *d2),
_ => return None,
},
CurveEval::Intersection(x) => return x.jet(t),
};
let speed2 = d1.len2();
if !(speed2 > 0.0) {
return None;
}
let tangent = d1 * (1.0 / speed2.sqrt());
// κ N = (r'' − (r''·T) T) / |r'|²: the part of the acceleration across the curve.
let curvature = (d2 - tangent * d2.dot(tangent)) * (1.0 / speed2);
let jet = CurveJet { point, tangent, curvature };
jet.is_finite().then_some(jet)
}
fn leader(&self, t: f64) -> Option<(Vec3, Vec3)> {
match self {
CurveEval::Polyline(p) => {
@@ -442,6 +498,41 @@ impl Intersection {
self.solve(lead, along)
}
/// The curve's [`CurveJet`] at `t`. The tangent is square to both surfaces' normals (along the
/// leader); the curvature vector lies in the plane of those normals, and its component along
/// each is that surface's normal curvature in the tangent's direction (Meusnier), from the
/// surface's exact partial derivatives ([`normal_section`]): two equations for its two
/// components. `None` where the surfaces are tangent (their normals do not span a plane).
fn jet(&self, t: f64) -> Option<CurveJet> {
let (lead, along) = self.leader.leader(t)?;
if !(along.len2() > 0.0) {
return None;
}
let point = self.solve(lead, along)?;
let mut feet = [(Vec3::ZERO, None); 2];
for (foot, s) in feet.iter_mut().zip(&self.surfaces) {
let q = s.project(point, None, f64::INFINITY)?;
let d = s.partials(q[0], q[1])?;
*foot = (d[1][0].cross(d[0][1]).normalized()?, Some(d));
}
let [(n0, Some(d0)), (n1, Some(d1))] = feet else { return None };
let mut tangent = n0.cross(n1).normalized()?;
if tangent.dot(along) < 0.0 {
tangent = -tangent;
}
let k0 = normal_section(&d0, n0, tangent)?.curvature;
let k1 = normal_section(&d1, n1, tangent)?.curvature;
// κN = a n0 + b n1 with κN·n0 = k0 and κN·n1 = k1.
let c = n0.dot(n1);
let det = 1.0 - c * c;
if !(det > 1e-12) {
return None;
}
let (a, b) = ((k0 - c * k1) / det, (k1 - c * k0) / det);
let jet = CurveJet { point, tangent, curvature: n0 * a + n1 * b };
jet.is_finite().then_some(jet)
}
/// Newton on the planes: each surface's tangent plane at the foot of the current point, and
/// the leader's normal plane, until the point stops moving.
fn solve(&self, lead: Vec3, along: Vec3) -> Option<Vec3> {
+49
View File
@@ -954,6 +954,55 @@ fn analytic_partials_are_the_derivatives_of_the_order_below() {
}
}
/// A curve's curvature vector, exactly, on every kind of record: a circle's is 1/R towards its
/// centre (as a conic and as SMLib's rational quadratic B-spline of it); an ellipse's at its
/// vertices is a/b² and b/a²; a line is straight; and the circle where a plane z = 3 cuts a
/// sphere of radius 5 about the origin, traced from a rough leader, has radius 4.
#[test]
fn curve_jets_are_exact_on_every_record() {
use crate::Vec3;
let near = |a: Vec3, b: Vec3| a.dist(b) <= 1e-9 * (1.0 + b.len());
let jet = |c: &Curve, t: f64| eval::CurveEval::new(c).unwrap().jet(t).unwrap();
let flat = Frame { origin: [1., 2., 3.], x_axis: [0.6, 0.8, 0.], y_axis: [-0.8, 0.6, 0.] };
let o = Vec3::new(1., 2., 3.);
for t in [0.0, 0.7, 2.5, 5.9] {
let j = jet(&Curve::Circle { frame: flat.clone(), radius: 4. }, t);
assert!(near(j.curvature, (o - j.point) * (1.0 / 16.0)), "{j:?}");
assert!(j.tangent.dot(j.curvature).abs() < 1e-12);
}
// A quarter circle of radius 2 as a rational quadratic.
let w = std::f64::consts::FRAC_1_SQRT_2;
let quarter =
Curve::RationalSpline { knots: vec![0., 0., 0., 1., 1., 1.], homogeneous: vec![[2., 0., 0., 1.], [2. * w, 2. * w, 0., w], [0., 2., 0., 1.]] };
for t in [0.0, 0.3, 0.5, 1.0] {
let j = jet(&quarter, t);
assert!(near(j.curvature, j.point * -0.25), "{t}: {j:?}");
}
let (a, b) = (10.0, 4.0);
let ellipse = Curve::Ellipse { frame: flat.clone(), radii: [a, b] };
assert!((jet(&ellipse, 0.0).curvature.len() - a / (b * b)).abs() < 1e-12);
assert!((jet(&ellipse, FRAC_PI_2).curvature.len() - b / (a * a)).abs() < 1e-12);
assert_eq!(jet(&Curve::Line { start: [0.; 3], end: [1., 2., 3.] }, 0.4).curvature, Vec3::ZERO);
// The plane z = 3 and a sphere of radius 5: a circle of radius 4 about (0, 0, 3).
let z = Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let leader = Curve::Polyline { points: (0..=12).map(|k| (k as f64 * TAU / 12.0).sin_cos()).map(|(s, c)| [4.1 * c, 4.1 * s, 3.2]).collect() };
let cut = Curve::SurfaceIntersection {
surfaces: [
Box::new(Surface::Plane { origin: [0., 0., 3.], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }),
Box::new(Surface::Sphere { frame: z, radius: 5. }),
],
leader: Box::new(leader),
tolerance: 1e-6,
};
for t in [0.0, 1.5, 4.25, 11.0] {
let j = jet(&cut, t);
let centre = Vec3::new(0., 0., 3.);
assert!((j.point.dist(centre) - 4.0).abs() < 1e-9 && (j.point.z - 3.0).abs() < 1e-12, "{j:?}");
assert!(near(j.curvature, (centre - j.point) * (1.0 / 16.0)), "{t}: {j:?}");
assert!(j.tangent.dot(j.point - centre).abs() < 1e-9);
}
}
#[test]
fn a_form_on_arcs_of_its_own_is_the_surface_and_does_not_close_around_an_open_face() {
// A torus face round 280° of its tube: on the quarter grid it needs all four quarters, a form
+172
View File
@@ -0,0 +1,172 @@
//! Curvature combs on a body's edges and along a face's isoparametric curves, from the exact
//! geometry: each tooth stands at an even step of the curve's own parameter and carries the
//! curvature vector there, worked out from the record's derivatives (never from the display
//! mesh), so a comb does not depend on the tessellation and has as many teeth as asked for.
//!
//! An edge's tooth is its curve's curvature vector `κ N` ([`CurveEval::jet`]: lines, conics,
//! polylines, B-splines and NURBS from their first two derivatives, a traced intersection from
//! both surfaces' normal curvatures). A face's tooth along an iso line is the surface's normal
//! curvature in the line's direction times the surface normal, `κₙ n` with
//! `κₙ = n · S_tt / |S_t|²` (the second fundamental form over the first, in that direction).
use solidcraft_geom::Vec3;
use solidcraft_geom::brep::eval::{CurveEval, SurfaceEval};
use crate::body::Body;
use crate::iso::IsoLine;
use crate::{KernelError, Result};
/// Fewest and most teeth along one curve.
pub const MIN_COMB_TEETH: usize = 2;
pub const MAX_COMB_TEETH: usize = 2000;
/// Samples along each edge for the first, coarse look for the edge nearest a point.
const PICK_SAMPLES: usize = 32;
/// Edges (nearest by the coarse look) whose distance is then found exactly.
const PICK_REFINED: usize = 8;
/// Golden-section steps of the exact distance: the bracket shrinks to 0.618⁹⁰ ≈ 10⁻¹⁹ of a sample step.
const PICK_STEPS: usize = 90;
/// One tooth of a comb.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CombTooth {
/// Where the tooth stands, on the curve.
pub at: Vec3,
/// The curvature vector there, 1/mm: towards the centre of curvature, its length the
/// curvature (a face's: the normal curvature along the iso line, along the surface normal).
pub curvature: Vec3,
}
fn check_density(density: usize) -> Result<()> {
if (MIN_COMB_TEETH..=MAX_COMB_TEETH).contains(&density) {
Ok(())
} else {
Err(KernelError::Invalid(format!("a comb has {MIN_COMB_TEETH}…{MAX_COMB_TEETH} teeth")))
}
}
/// The station fractions of `density` teeth: both ends of an open curve, every step of a closed
/// one once (its end is its start).
fn fractions(density: usize, closed: bool) -> impl Iterator<Item = f64> {
let steps = if closed { density } else { density.saturating_sub(1).max(1) };
(0..density).map(move |k| k as f64 / steps as f64)
}
/// The edge of `body` nearest `at` and its exact distance: the distance to the edge's own curve
/// over its interval, not to a tessellation. A periodic face's own seam (an edge whose uses are
/// all on one face, the face meeting itself) and a degenerate edge (a pole) are not edges a comb
/// is drawn on. `None` when the body has no such edge.
pub fn comb_edge_near(body: &Body, at: Vec3) -> Result<Option<(f64, usize)>> {
body.require_brep("a curvature comb")?;
if !at.is_finite() {
return Err(KernelError::Invalid("the point must be finite".into()));
}
crate::guard("curvature comb pick", || {
let record = body.record();
let mut coarse: Vec<(f64, usize, usize)> = Vec::new();
for (index, edge) in record.edges.iter().enumerate() {
let seam = edge.uses.len() >= 2 && edge.uses.iter().all(|u| edge.uses.first().is_some_and(|f| f.face == u.face));
let Ok(curve) = CurveEval::new(edge.parameters()) else { continue };
let [t0, t1] = edge.interval;
if seam || curve.jet(0.5 * (t0 + t1)).is_none() {
continue;
}
let best = (0..=PICK_SAMPLES)
.filter_map(|k| curve.point(t0 + (t1 - t0) * k as f64 / PICK_SAMPLES as f64).map(|q| (q.dist(at), k)))
.filter(|(d, _)| d.is_finite())
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((d, k)) = best {
coarse.push((d, index, k));
}
}
coarse.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut best: Option<(f64, usize)> = None;
for &(_, index, k) in coarse.iter().take(PICK_REFINED) {
let Some(edge) = record.edges.get(index) else { continue };
let Ok(curve) = CurveEval::new(edge.parameters()) else { continue };
let Some(d) = nearest_on(&curve, edge.interval, k, at) else { continue };
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, index));
}
}
Ok(best)
})
}
/// The distance from `at` to `curve` on `[t0, t1]`, by golden-section search on the sample steps
/// either side of sample `k` (the nearest of [`PICK_SAMPLES`] + 1), ends included.
fn nearest_on(curve: &CurveEval, [t0, t1]: [f64; 2], k: usize, at: Vec3) -> Option<f64> {
let step = (t1 - t0) / PICK_SAMPLES as f64;
let mid = t0 + step * k as f64;
let (mut a, mut b) = ((mid - step).max(t0.min(t1)), (mid + step).min(t0.max(t1)));
let dist = |t: f64| curve.point(t).map(|q| q.dist(at)).filter(|d| d.is_finite());
let ratio = 0.5 * (5f64.sqrt() - 1.0);
let mut best = dist(mid)?;
let (mut x, mut y) = (b - (b - a) * ratio, a + (b - a) * ratio);
let (mut fx, mut fy) = (dist(x)?, dist(y)?);
for _ in 0..PICK_STEPS {
if fx < fy {
(b, y, fy) = (y, x, fx);
x = b - (b - a) * ratio;
fx = dist(x)?;
} else {
(a, x, fx) = (x, y, fy);
y = a + (b - a) * ratio;
fy = dist(y)?;
}
}
for d in [fx, fy, dist(a)?, dist(b)?] {
best = best.min(d);
}
Some(best)
}
/// The comb of edge `edge` of `body` (its index in the body's edge order): `density` teeth at
/// even steps of the edge curve's parameter over the edge, ends included (a closed edge's end is
/// its start, once), each with the curve's exact curvature vector. Stations where the curve has
/// no tangent are left out.
pub fn edge_comb(body: &Body, edge: usize, density: usize) -> Result<Vec<CombTooth>> {
body.require_brep("a curvature comb")?;
check_density(density)?;
crate::guard("curvature comb", || {
let e = body.record().edges.get(edge).ok_or_else(|| KernelError::Invalid(format!("the body has no edge {edge}")))?;
let curve = CurveEval::new(e.parameters()).map_err(|err| KernelError::Failed(format!("curvature comb: {err}")))?;
let [t0, t1] = e.interval;
let closed = e.vertices[0] == e.vertices[1];
Ok(fractions(density, closed)
.filter_map(|f| curve.jet(t0 + (t1 - t0) * f))
.map(|j| CombTooth { at: j.point, curvature: j.curvature })
.collect())
})
}
/// The comb along `line`, an isoparametric curve of face `face` of `body` ([`crate::iso_grid`]):
/// `density` teeth at even steps of the surface parameter that runs along it, ends included, each
/// the surface's normal curvature in the line's direction along its normal, from the surface's
/// exact partial derivatives. Stations at a pole (no tangent along the line) are left out.
pub fn iso_comb(body: &Body, face: usize, line: &IsoLine, density: usize) -> Result<Vec<CombTooth>> {
body.require_brep("a curvature comb")?;
check_density(density)?;
crate::guard("curvature comb", || {
let f = body.record().faces.get(face).ok_or_else(|| KernelError::Invalid(format!("the body has no face {face}")))?;
let surface = SurfaceEval::new(f.parameters()).map_err(|err| KernelError::Failed(format!("curvature comb: {err}")))?;
let [s0, s1] = line.span;
let mut out = Vec::with_capacity(density);
for frac in fractions(density, false) {
let s = s0 + (s1 - s0) * frac;
let [u, v] = if line.along_u { [s, line.fixed] } else { [line.fixed, s] };
let Some(d) = surface.partials(u, v) else { continue };
let (along, bend) = if line.along_u { (d[1][0], d[2][0]) } else { (d[0][1], d[0][2]) };
let speed2 = along.len2();
let Some(n) = d[1][0].cross(d[0][1]).normalized() else { continue };
if !(speed2 > 0.0) {
continue;
}
let curvature = n * (n.dot(bend) / speed2);
if d[0][0].is_finite() && curvature.is_finite() {
out.push(CombTooth { at: d[0][0], curvature });
}
}
Ok(out)
})
}
+14 -8
View File
@@ -35,6 +35,9 @@ pub struct IsoLine {
/// The exact record curve on `interval` of its own parameter.
pub curve: brep::Curve,
pub interval: [f64; 2],
/// The surface parameter that runs along the curve (u when `along_u`, else v), from the
/// start of `interval` to its end.
pub span: [f64; 2],
/// Its points from the start of `interval` to its end, at the chord tolerance asked for.
pub points: Vec<Vec3>,
}
@@ -104,9 +107,10 @@ impl<'a> FaceTrims<'a> {
}
/// The iso line of the surface at `fixed` (v when `along_u`, else u) across the face's box,
/// cut to the face: the pieces inside it, each as its exact record curve on its interval, in
/// the order the line runs (none when the line misses the face).
fn clip(&self, along_u: bool, fixed: f64) -> Result<Vec<(brep::Curve, [f64; 2])>> {
/// cut to the face: the pieces inside it, each as its exact record curve on its interval and
/// the running parameter's span, in the order the line runs (none when the line misses the
/// face).
fn clip(&self, along_u: bool, fixed: f64) -> Result<Vec<(brep::Curve, [f64; 2], [f64; 2])>> {
let [[u0, u1], [v0, v1]] = self.domain;
let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
let at_t = |t: f64| if along_u { [t, fixed] } else { [fixed, t] };
@@ -149,11 +153,13 @@ impl<'a> FaceTrims<'a> {
if self.inside(at_t(0.5 * (a + b))) {
run = Some(run.map_or((a, b), |(s, _)| (s, b)));
} else if let Some(r) = run.take() {
out.push(iso_record(self.surface, along_u, fixed, r)?);
let (curve, interval) = iso_record(self.surface, along_u, fixed, r)?;
out.push((curve, interval, [r.0, r.1]));
}
}
if let Some(r) = run {
out.push(iso_record(self.surface, along_u, fixed, r)?);
let (curve, interval) = iso_record(self.surface, along_u, fixed, r)?;
out.push((curve, interval, [r.0, r.1]));
}
Ok(out)
}
@@ -184,7 +190,7 @@ pub fn iso_curves(body: &Body, face: usize, at: Vec3, along_u: bool) -> Result<V
}
}
}
let out = trims.clip(along_u, if along_u { pv } else { pu })?;
let out: Vec<(brep::Curve, [f64; 2])> = trims.clip(along_u, if along_u { pv } else { pu })?.into_iter().map(|(c, i, _)| (c, i)).collect();
if out.is_empty() {
return Err(KernelError::Invalid("the iso curve runs outside the face".into()));
}
@@ -216,13 +222,13 @@ pub fn iso_grid(body: &Body, face: usize, count: usize, chord: f64) -> Result<Ve
}
for k in 1..=count {
let fixed = lo + (hi - lo) * k as f64 / (count + 1) as f64;
for (curve, interval) in trims.clip(along_u, fixed)? {
for (curve, interval, span) in trims.clip(along_u, fixed)? {
let segs = curve_path(&curve, interval)?;
let mut points: Vec<Vec3> = segs.first().map(crate::PathSeg::start).into_iter().collect();
for s in &segs {
points.extend(s.points_after_start(chord));
}
out.push(IsoLine { along_u, fixed, curve, interval, points });
out.push(IsoLine { along_u, fixed, curve, interval, span, points });
}
}
}
+2
View File
@@ -32,6 +32,7 @@ mod continuity_order;
#[cfg(test)]
mod coplanar_tests;
mod corner_g2;
mod curvature_comb;
mod delete_face;
mod distance;
mod draft_corners;
@@ -174,6 +175,7 @@ pub use continuity::{
};
pub use continuity_order::{Construction, Continuity};
pub use corner_g2::{CURVATURE_SHARE, DEFAULT_SETBACK_FACTOR};
pub use curvature_comb::{CombTooth, MAX_COMB_TEETH, MIN_COMB_TEETH, comb_edge_near, edge_comb, iso_comb};
pub use delete_face::delete_faces;
pub use distance::{BodyDistance, DistanceShape, body_distance};
pub use draft_corners::{CornerFill, CornerOption, CornerRefusal, CornerReport, DraftCorner, draft_corners};