From 319386760f3e0f842211eb659fe3de1e43bb102f Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 07:38:22 +0000 Subject: [PATCH] Isocurve Analysis (FusionIsoCurveAnalysisCommand): a grid of isoparametric curves each way across picked faces (the face cut by evenly spaced planes of each family), drawn while the tool is active Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/lib.rs | 1 + crates/doc/src/project3d.rs | 49 +++++++++++++++++++++++++ crates/engine/src/cmd/sketch_inspect.rs | 40 +++++++++++++++++++- crates/ui-egui/src/icons.rs | 8 ++++ crates/ui-egui/src/sketch_tools.rs | 36 +++++++++++++++++- 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 05f4a2a3..c3ec9de3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ mod params; pub mod plastic; pub mod project; mod project3d; +pub use project3d::iso_grid; pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs index 6b8e0a7b..2541252d 100644 --- a/crates/doc/src/project3d.rs +++ b/crates/doc/src/project3d.rs @@ -351,6 +351,55 @@ pub(crate) fn iso_curve(m: &Mesh, f: u32, p: Vec3, dir: &str) -> Vec> if len(&a) >= len(&b) { a } else { b } } +/// Isocurve analysis: `count` curves each way across face `f` (picked at `p`), as world +/// polylines. Each family is the face cut by parallel planes spaced evenly over the face. +pub fn iso_grid(m: &Mesh, f: u32, p: Vec3, count: usize) -> Vec> { + let count = count.clamp(1, 100); + let verts: Vec = m.triangles.iter().zip(&m.tri_face).filter(|(_, x)| **x == f).filter_map(|(t, _)| m.tri(t)).flatten().collect(); + if verts.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for dir in ["u", "v"] { + // The family's cutting direction, from the curve through p. + let Some(first) = iso_curve(m, f, p, dir).into_iter().next() else { continue }; + let pts: Vec = first.iter().copied().collect(); + // Normal of the plane that curve lies in. + let Some(c) = pts.first().copied() else { continue }; + let mut n = Vec3::ZERO; + for w in pts.windows(2) { + n = n + (w[0] - c).cross(w[1] - c); + } + let cut_n = match n.normalized() { + Some(n) => n, + // A straight curve: across it, within the face. + None => { + let d = pts.last().copied().unwrap_or(c) - c; + let np = (verts[1] - verts[0]).cross(verts[2] - verts[0]); + match d.cross(np).normalized() { + Some(x) => x, + None => continue, + } + } + }; + let (lo, hi) = verts.iter().map(|v| v.dot(cut_n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(x), b.max(x))); + if !(hi - lo > 1e-9) { + continue; + } + for k in 1..=count { + let d = lo + (hi - lo) * k as f64 / (count + 1) as f64; + let Some(pl) = Plane::from_normal(cut_n * d, cut_n) else { continue }; + for g in crate::project::section(&pl, m, Some(f)) { + let w: Vec = link_polyline(&g).into_iter().map(|q| pl.to_world(q)).collect(); + if w.len() >= 2 { + out.push(w); + } + } + } + } + out +} + /// Join polylines whose ends meet into longer ones. fn join_wires(mut ws: Vec>, tol: f64) -> Vec> { let mut out: Vec> = Vec::new(); diff --git a/crates/engine/src/cmd/sketch_inspect.rs b/crates/engine/src/cmd/sketch_inspect.rs index 02e7e102..53599d6b 100644 --- a/crates/engine/src/cmd/sketch_inspect.rs +++ b/crates/engine/src/cmd/sketch_inspect.rs @@ -19,6 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("min_radius") .noundo() .params("curves?: [sketch curve ids] (sketch?) | edges?: [[x,y,z]…] | faces?: [[x,y,z]…] (default: every face of every body); the smallest radius of curvature of each and where it is"), + CommandSpec::new("FusionIsoCurveAnalysisCommand", "Isocurve Analysis", isocurves) + .at("SKETCH", "INSPECT") + .icon("iso_analysis") + .noundo() + .params("faces: [[x,y,z]…] points on faces; count?: curves each way (default 8): the faces' isoparametric curves"), CommandSpec::new("FusionCenterOfMassCommand", "Center of Mass", center_of_mass) .at("SKETCH", "INSPECT") .icon("center_of_mass") @@ -276,6 +281,26 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result { })) } +fn isocurves(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionIsoCurveAnalysisCommand"; + let count = num(p, "count").map(|c| c.clamp(1.0, 100.0) as usize).unwrap_or(8); + let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?; + if faces.is_empty() { + return Err(bad(cmd, "`faces` is empty")); + } + let st = s.model.state(); + let mut out = Vec::new(); + for v in faces.iter().take(100) { + let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?; + let (bi, f) = face_at(&st, at).ok_or_else(|| bad(cmd, "no face there"))?; + let m = st.bodies.get(bi).map(|b| b.mesh()).ok_or_else(|| bad(cmd, "body"))?; + for w in solvecraft_doc::iso_grid(&m, f, at, count) { + out.push(w.iter().map(|q| [q.x, q.y, q.z]).collect::>()); + } + } + Ok(json!({"curves": out})) +} + fn center_of_mass(s: &mut Session, p: &Value) -> Result { let cmd = "FusionCenterOfMassCommand"; let names = string_list(p, "bodies"); @@ -300,7 +325,7 @@ fn center_of_mass(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -349,5 +374,18 @@ mod tests { assert!((r["center"][0].as_f64().unwrap() - want).abs() < 0.1, "{r}"); assert!((r["center"][2].as_f64().unwrap() - 5.0).abs() < 0.05, "{r}"); assert!(s.execute("FusionCenterOfMassCommand", &json!({"bodies": ["nope"]})).is_err()); + // Isocurves on the cylinder's side: rings around it and lines along it. + let r = s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap(); + let curves = r["curves"].as_array().unwrap(); + let ring = + |c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 0.2); + assert!(curves.len() >= 8, "{}", curves.len()); + assert!(curves.iter().all(ring), "every curve lies on the cylinder"); + let flat = |c: &Value| { + let z: Vec = c.as_array().unwrap().iter().map(|q| q[2].as_f64().unwrap()).collect(); + z.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - z.iter().cloned().fold(f64::INFINITY, f64::min) < 1e-6 + }; + assert!(curves.iter().filter(|c| flat(c)).count() >= 4, "rings at constant height"); + assert!(s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": []})).is_err()); } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b8a8921c..2238d588 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -644,6 +644,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.arrow((12.0, 10.5), (15.5, 7.0), a); pen.dot(12.0, 10.5, ink); } + "iso_analysis" => { + pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (15.0, 19.0)], pen.fill, ink); + for k in 1..4 { + let t = k as f32 / 4.0; + pen.line(&[(3.0 + 6.0 * t, 17.0 - 12.0 * t), (15.0 + 6.0 * t, 19.0 - 12.0 * t)], a); + pen.line(&[(3.0 + 12.0 * t, 17.0 + 2.0 * t), (9.0 + 12.0 * t, 5.0 + 2.0 * t)], a); + } + } "center_of_mass" => { pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); let q = |a0: f32| { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index c5c8d04a..70dff402 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -30,6 +30,8 @@ thread_local! { static IMAGE: RefCell> = const { RefCell::new(None) }; /// Analysis markers shown while an analysis tool is active: (where, label). static MARKS: RefCell> = const { RefCell::new(Vec::new()) }; + /// Isocurve analysis polylines (as segments) while the tool is active. + static ISO: RefCell>> = const { RefCell::new(Vec::new()) }; /// Center of Mass has just started: measure everything on the next frame. static DEFERRED: Cell = const { Cell::new(false) }; /// The canvas whose panel is open. @@ -52,6 +54,24 @@ fn analysis(app: &mut SolveApp, cmd: &str, p: Value) { } } +/// Isocurve analysis lines. +fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if app.tool.as_ref().map(|t| t.cmd) != Some("FusionIsoCurveAnalysisCommand") { + ISO.with(|i| i.borrow_mut().clear()); + return; + } + let tk = crate::theme::Tokens::get(); + ISO.with(|i| { + for c in i.borrow().iter() { + for [a, b] in c { + if let (Some(p), Some(q)) = (proj.to_screen(*a), proj.to_screen(*b)) { + painter.line_segment([p, q], Stroke::new(1.2, tk.accent)); + } + } + } + }); +} + /// Analysis markers: a target with its label. fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand")) { @@ -338,7 +358,7 @@ fn mode(id: &str) -> Option { Some(match id { "ProjectNewCmd" | "IntersectCmd" | "Include3DGeometry" | "FitCurvesToSectionCommand" | "SketchIsoparametricCurve" => Mode::ModelRef, "FusionCurvatureCombAnalysisCommand" | "FusionAddCanvasCommand" | "FusionAddEditDecalCommand" => Mode::ModelRef, - "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand" => Mode::ModelRef, + "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand" | "FusionIsoCurveAnalysisCommand" => Mode::ModelRef, "TrimSketchCmd" | "ExtendSketchCmd" | "BreakSketchCmd" => Mode::CurveAt, "SketchMidpointLine" => Mode::Points(2), "ArcTangent" => Mode::Points(2), @@ -386,6 +406,7 @@ pub fn hint(id: &str) -> Option { "FusionCurvatureCombAnalysisCommand" => "Curvature comb: click sketch curves or model edges", "FusionMinimumRadiusAnalysisCommand" => "Minimum radius: click sketch curves, edges or faces", "FusionCenterOfMassCommand" => "Center of mass of all bodies; click a body for its own", + "FusionIsoCurveAnalysisCommand" => "Isocurves: click faces", "FusionAddCanvasCommand" => "Canvas: click a plane or a planar face where the image's centre goes", "FusionAddEditDecalCommand" => "Decal: click a planar face where the image's centre goes", "TrimSketchCmd" => "Trim: click the piece of a curve to remove", @@ -526,6 +547,18 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } } + Mode::ModelRef if cmd == "FusionIsoCurveAnalysisCommand" => { + let face = hits.iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }); + if let Some(at) = face + && let Ok(v) = app.run(cmd, json!({"faces": [[at.x, at.y, at.z]]})) + { + for c in v["curves"].as_array().into_iter().flatten() { + let pts: Vec = c.as_array().into_iter().flatten().filter_map(v3).collect(); + let teeth: Vec<[Vec3; 2]> = pts.windows(2).map(|w| [w[0], w[1]]).collect(); + ISO.with(|i| i.borrow_mut().push(teeth)); + } + } + } Mode::ModelRef if matches!(cmd, "FusionMinimumRadiusAnalysisCommand" | "FusionCenterOfMassCommand") => { let params = hits.iter().find_map(|h| match (cmd, h) { ("FusionMinimumRadiusAnalysisCommand", Hit::SketchCurve { sketch, id, .. }) => Some(json!({"sketch": sketch, "curves": [id]})), @@ -989,6 +1022,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P analysis(app, "FusionCenterOfMassCommand", json!({})); } marks(app, painter, proj); + iso_lines(app, painter, proj); text_entry(app, ui.ctx()); canvas_panel(app, ui.ctx()); }