Curvature Comb Analysis (FusionCurvatureCombAnalysisCommand): combs on sketch curves (exact, from their Bézier/conic pieces) and model edges, with minimum radius; interactive tool draws teeth and envelope; ignored large-sketch timing test (sketch_perf)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
7382ebd6e8
commit
4c64ceed5c
5 files changed
+289
-1
No files matched your search
@@ -23,6 +23,7 @@ mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
mod sketch_freeform;
|
||||
mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
|
||||
@@ -124,6 +125,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_create::COMMANDS.iter());
|
||||
v.extend(sketch_constraints::COMMANDS.iter());
|
||||
v.extend(sketch_import::COMMANDS.iter());
|
||||
v.extend(sketch_inspect::COMMANDS.iter());
|
||||
v.extend(sketch_modify::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
|
||||
@@ -528,3 +528,7 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_fuzz_tests.rs"]
|
||||
mod fuzz;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_perf_tests.rs"]
|
||||
mod perf;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Sketch inspection: curvature combs on sketch curves and model edges.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::{Seg2, Vec2, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, string_list, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb)
|
||||
.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]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)")];
|
||||
|
||||
/// Curvature (signed, 1/mm) and unit normal of a segment at `t`.
|
||||
fn seg_curvature(s: &Seg2, t: f64) -> (f64, Vec2) {
|
||||
let d1 = s.derivative(t);
|
||||
let h = 1e-5;
|
||||
let (ta, tb) = ((t - h).max(0.0), (t + h).min(1.0));
|
||||
let d2 = (s.derivative(tb) - s.derivative(ta)) / (tb - ta).max(1e-12);
|
||||
let l = d1.len().max(1e-300);
|
||||
let k = d1.cross(d2) / (l * l * l);
|
||||
(k, d1.perp() / l)
|
||||
}
|
||||
|
||||
/// Teeth along a 2D curve: (foot, curvature, normal).
|
||||
fn teeth_2d(segs: &[Seg2], density: usize) -> Vec<(Vec2, f64, Vec2)> {
|
||||
let per = density.div_ceil(segs.len().max(1)).max(2);
|
||||
let mut out = Vec::new();
|
||||
for s in segs {
|
||||
for i in 0..=per {
|
||||
let t = i as f64 / per as f64;
|
||||
let (k, n) = seg_curvature(s, t);
|
||||
out.push((s.point_at(t), k, n));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionCurvatureCombAnalysisCommand";
|
||||
let density = num(p, "density").map(|d| d.clamp(2.0, 2000.0) as usize).unwrap_or(40);
|
||||
let st = s.model.state();
|
||||
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
|
||||
let curves = string_list(p, "curves");
|
||||
if !curves.is_empty() {
|
||||
let id = match p.get("sketch") {
|
||||
Some(Value::Number(n)) => n.as_u64(),
|
||||
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
|
||||
_ => s.active_sketch,
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?;
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
|
||||
for c in curves.iter().take(1000) {
|
||||
let ci = ss.sketch.curve_index(c).ok_or_else(|| bad(cmd, format!("unknown curve `{c}`")))?;
|
||||
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
|
||||
let teeth = teeth_2d(&segs, density).into_iter().map(|(q, k, n)| (ss.plane.to_world(q), k, ss.plane.dir_to_world(n))).collect();
|
||||
combs.push((c.clone(), teeth));
|
||||
}
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
if combs.is_empty() {
|
||||
return Err(bad(cmd, "give `curves` or `edges`"));
|
||||
}
|
||||
// One scale for all combs: the longest tooth is a fifth of the largest curve's size.
|
||||
let kmax = combs.iter().flat_map(|c| c.1.iter().map(|t| t.1.abs())).fold(0.0_f64, f64::max);
|
||||
let size = combs.iter().flat_map(|c| c.1.iter().map(|t| t.0)).fold(
|
||||
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)),
|
||||
|(lo, hi), q| (lo.min(q), hi.max(q)),
|
||||
);
|
||||
let diag = (size.1 - size.0).len();
|
||||
let scale = num(p, "scale").filter(|x| *x > 0.0).unwrap_or(if kmax > 1e-12 && diag.is_finite() { 0.2 * diag / kmax } else { 1.0 });
|
||||
let out: Vec<Value> = combs
|
||||
.iter()
|
||||
.map(|(id, teeth)| {
|
||||
let lines: Vec<[[f64; 3]; 2]> = teeth.iter().map(|(q, k, n)| [(*q).into(), (*q - *n * (k * scale)).into()]).collect();
|
||||
let kmax_c = teeth.iter().map(|t| t.1.abs()).fold(0.0_f64, f64::max);
|
||||
json!({
|
||||
"curve": id,
|
||||
"teeth": lines,
|
||||
"envelope": lines.iter().map(|l| l[1]).collect::<Vec<_>>(),
|
||||
"max_curvature": kmax_c,
|
||||
"min_radius": if kmax_c > 1e-12 { Value::from(1.0 / kmax_c) } else { Value::Null },
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"combs": out, "scale": scale}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn curvature_combs() {
|
||||
let mut s = Session::default();
|
||||
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
|
||||
let c = s.execute("CircleCenterRadius", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
|
||||
let l = s.execute("DrawPolyline", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
|
||||
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"curves": [c, l], "density": 20})).unwrap();
|
||||
let circle = &r["combs"][0];
|
||||
assert!((circle["min_radius"].as_f64().unwrap() - 5.0).abs() < 1e-3, "{circle}");
|
||||
assert!(r["combs"][1]["min_radius"].is_null(), "a line is straight");
|
||||
// Circle teeth stand out on the convex side, away from the centre.
|
||||
let t = &circle["teeth"][3];
|
||||
let (a, b) = ((t[0][0].as_f64().unwrap(), t[0][1].as_f64().unwrap()), (t[1][0].as_f64().unwrap(), t[1][1].as_f64().unwrap()));
|
||||
assert!(b.0 * b.0 + b.1 * b.1 > a.0 * a.0 + a.1 * a.1);
|
||||
s.execute("SketchStop", &json!({})).unwrap();
|
||||
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
|
||||
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"edges": [[58, 0, 10]]})).unwrap();
|
||||
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
|
||||
assert!(s.execute("FusionCurvatureCombAnalysisCommand", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Timings for large sketches (run with `cargo test -p solvecraft-engine --release sketch_perf -- --ignored --nocapture`;
|
||||
//! numbers are recorded in docs/perf.md).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// A sketch of `n` constrained rectangles (4n lines, each dimensioned) plus `n` circles inside
|
||||
/// them, built straight into the document.
|
||||
fn big(n: usize) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
|
||||
let id = s.active_sketch.unwrap();
|
||||
let mut doc = (*s.doc).clone();
|
||||
let sk = doc.sketch_mut(id).unwrap();
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::ConstraintKind::*;
|
||||
let side = (n as f64).sqrt().ceil() as usize;
|
||||
for k in 0..n {
|
||||
let (x, y) = ((k % side) as f64 * 30.0, (k / side) as f64 * 30.0);
|
||||
let p: Vec<usize> = [(0.0, 0.0), (20.0, 0.3), (20.2, 15.0), (-0.1, 15.1)]
|
||||
.iter()
|
||||
.map(|(dx, dy)| sk.add_point(Vec2::new(x + dx, y + dy), None).unwrap())
|
||||
.collect();
|
||||
let l: Vec<usize> = (0..4).map(|i| sk.add_line_pts(p[i], p[(i + 1) % 4], None).unwrap()).collect();
|
||||
sk.add_constraint(Horizontal { l: l[0] }, None).unwrap();
|
||||
sk.add_constraint(Horizontal { l: l[2] }, None).unwrap();
|
||||
sk.add_constraint(Vertical { l: l[1] }, None).unwrap();
|
||||
sk.add_constraint(Vertical { l: l[3] }, None).unwrap();
|
||||
sk.add_constraint(Length { l: l[0], value: 20.0 }, None).unwrap();
|
||||
sk.add_constraint(Length { l: l[1], value: 15.0 }, None).unwrap();
|
||||
sk.add_circle(Vec2::new(x + 10.0, y + 7.5), 3.0, None, None).unwrap();
|
||||
}
|
||||
*s.doc_mut() = doc;
|
||||
s.refresh();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn sketch_perf() {
|
||||
for n in [250usize, 500, 1000] {
|
||||
let t = Instant::now();
|
||||
let mut s = big(n);
|
||||
let build = ms(t);
|
||||
let id = s.active_sketch.unwrap();
|
||||
let sk = s.doc.sketch(id).unwrap().clone();
|
||||
let entities = sk.curves.len() + sk.points.len();
|
||||
let t = Instant::now();
|
||||
let mut k = sk.clone();
|
||||
let rep = solvecraft_sketch::solve(&mut k);
|
||||
let solve = ms(t);
|
||||
let t = Instant::now();
|
||||
let prof = solvecraft_sketch::find_profiles(&k);
|
||||
let profiles = ms(t);
|
||||
let ss = s.model.state().sketch(id).unwrap().clone();
|
||||
let t = Instant::now();
|
||||
let lines = crate::view::sketch_lines(&ss.sketch, &ss.plane, true, &ss.report.curve_determined);
|
||||
let draw = ms(t);
|
||||
let t = Instant::now();
|
||||
s.execute("sketch.snap", &json!({"at": [10.0, 0.2], "from": [0, 0], "radius": 1})).unwrap();
|
||||
let snap = ms(t);
|
||||
let t = Instant::now();
|
||||
s.execute("DrawPolyline", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
|
||||
let add = ms(t);
|
||||
let t = Instant::now();
|
||||
s.execute("sketch.move_point", &json!({"point": "p1", "to": [1, 1]})).unwrap();
|
||||
let drag = ms(t);
|
||||
eprintln!(
|
||||
"PERF n={n} curves={} points={} entities={entities} constraints={} dof={} profiles={}: build {build:.0} ms, solve {solve:.0} ms, profiles {profiles:.0} ms, draw lists {draw:.1} ms ({} polylines), snap {snap:.1} ms, add a line {add:.0} ms, drag a point {drag:.0} ms",
|
||||
sk.curves.len(),
|
||||
sk.points.len(),
|
||||
sk.constraints.len(),
|
||||
rep.dof,
|
||||
prof.len(),
|
||||
lines.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::cell::{Cell, RefCell};
|
||||
|
||||
use egui::{Pos2, Stroke};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
use solvecraft_engine::geom::{Vec2, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::tools::{Kind, Tool};
|
||||
@@ -24,6 +24,32 @@ thread_local! {
|
||||
/// The inference the cursor snapped to last (label, sketch point), for the hint.
|
||||
static SNAP: RefCell<Option<(&'static str, Vec2)>> = const { RefCell::new(None) };
|
||||
static PREV: Cell<Option<Vec2>> = const { Cell::new(None) };
|
||||
/// Curvature comb teeth (foot, tip) shown while the comb tool is active.
|
||||
static COMBS: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<Vec3> {
|
||||
Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))
|
||||
}
|
||||
|
||||
/// Curvature combs: teeth and the envelope through their tips.
|
||||
fn combs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if app.tool.as_ref().map(|t| t.cmd) != Some("FusionCurvatureCombAnalysisCommand") {
|
||||
COMBS.with(|c| c.borrow_mut().clear());
|
||||
return;
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
COMBS.with(|c| {
|
||||
for comb in c.borrow().iter() {
|
||||
let mut tips: Vec<Pos2> = Vec::new();
|
||||
for [a, b] in comb {
|
||||
let (Some(p), Some(q)) = (proj.to_screen(*a), proj.to_screen(*b)) else { continue };
|
||||
painter.line_segment([p, q], Stroke::new(1.0, tk.warning.gamma_multiply(0.8)));
|
||||
tips.push(q);
|
||||
}
|
||||
painter.add(egui::Shape::line(tips, Stroke::new(1.4, tk.warning)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Snap a cursor point on the sketch plane to what Fusion infers while drawing: a line's
|
||||
@@ -149,6 +175,7 @@ enum Mode {
|
||||
fn mode(id: &str) -> Option<Mode> {
|
||||
Some(match id {
|
||||
"ProjectNewCmd" | "IntersectCmd" | "Include3DGeometry" | "FitCurvesToSectionCommand" | "SketchIsoparametricCurve" => Mode::ModelRef,
|
||||
"FusionCurvatureCombAnalysisCommand" => Mode::ModelRef,
|
||||
"TrimSketchCmd" | "ExtendSketchCmd" | "BreakSketchCmd" => Mode::CurveAt,
|
||||
"SketchMidpointLine" => Mode::Points(2),
|
||||
"ArcTangent" => Mode::Points(2),
|
||||
@@ -193,6 +220,7 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"Include3DGeometry" => "Include 3D Geometry: click edges or vertices",
|
||||
"FitCurvesToSectionCommand" => "Fit Curves to Mesh Section: click a body",
|
||||
"SketchIsoparametricCurve" => "Isoparametric Curve: click a point on a face (Shift: along)",
|
||||
"FusionCurvatureCombAnalysisCommand" => "Curvature comb: click sketch curves or model edges",
|
||||
"TrimSketchCmd" => "Trim: click the piece of a curve to remove",
|
||||
"ExtendSketchCmd" => "Extend: click a curve near the end to extend",
|
||||
"BreakSketchCmd" => "Break: click a curve where it should split",
|
||||
@@ -315,6 +343,22 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
let at = sketch_point_at(app, proj, pos);
|
||||
let cmd = tool.cmd;
|
||||
match m {
|
||||
Mode::ModelRef if cmd == "FusionCurvatureCombAnalysisCommand" => {
|
||||
let params = hits.iter().find_map(|h| match h {
|
||||
Hit::SketchCurve { sketch, id, .. } => Some(json!({"sketch": sketch, "curves": [id]})),
|
||||
Hit::Edge { mid, .. } => Some(json!({"edges": [[mid.x, mid.y, mid.z]]})),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(p) = params
|
||||
&& let Ok(v) = app.run(cmd, p)
|
||||
{
|
||||
for comb in v["combs"].as_array().into_iter().flatten() {
|
||||
let teeth: Vec<[Vec3; 2]> =
|
||||
comb["teeth"].as_array().into_iter().flatten().filter_map(|t| Some([v3(t.get(0)?)?, v3(t.get(1)?)?])).collect();
|
||||
COMBS.with(|c| c.borrow_mut().push(teeth));
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::ModelRef => {
|
||||
let r = hits.iter().find_map(|h| model_ref(app, h));
|
||||
let Some(r) = r else { return };
|
||||
@@ -724,6 +768,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
|
||||
glyphs(app, painter, proj);
|
||||
snap_hint(app, painter, proj);
|
||||
combs(app, painter, proj);
|
||||
text_entry(app, ui.ctx());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user