mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 03:25:58 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user