diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8a5a248a..0485bd24 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index dc226887..8f06c1a6 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -528,3 +528,7 @@ mod tests; #[cfg(test)] #[path = "sketch_fuzz_tests.rs"] mod fuzz; + +#[cfg(test)] +#[path = "sketch_perf_tests.rs"] +mod perf; diff --git a/crates/engine/src/cmd/sketch_inspect.rs b/crates/engine/src/cmd/sketch_inspect.rs new file mode 100644 index 00000000..af104386 --- /dev/null +++ b/crates/engine/src/cmd/sketch_inspect.rs @@ -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 { + 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)> = 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 = 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::>(), + "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()); + } +} diff --git a/crates/engine/src/cmd/sketch_perf_tests.rs b/crates/engine/src/cmd/sketch_perf_tests.rs new file mode 100644 index 00000000..81122a92 --- /dev/null +++ b/crates/engine/src/cmd/sketch_perf_tests.rs @@ -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 = [(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 = (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() + ); + } +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 2c8e615c..391d3ece 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -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> = const { RefCell::new(None) }; static PREV: Cell> = const { Cell::new(None) }; + /// Curvature comb teeth (foot, tip) shown while the comb tool is active. + static COMBS: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +fn v3(v: &Value) -> Option { + 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 = 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 { 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 { "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()); }