From 99385b1e316e8b5bd138804f0ea9ca6174def6f4 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 09:40:50 +0000 Subject: [PATCH] Fillets and chamfers keep their edges through upstream edits: each picked edge remembers its direction and its body's bounding box when picked (Feature.edge_refs, set by the commands and by timeline.edit); a reference point that is off every edge after an edit is carried to the same place in the body as it is now and kept when an edge running the same way is there (QA s06: a fillet on the right top edge follows the edge when the sketch widens); otherwise the nearest edge with a warning as before Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 6 ++- crates/doc/src/eval.rs | 66 ++++++++++++++++++++++++++++--- crates/doc/src/eval_more.rs | 2 +- crates/doc/src/lib.rs | 4 +- crates/engine/src/cmd/edit.rs | 9 +++++ crates/engine/src/cmd/features.rs | 12 ++++++ crates/engine/src/tests.rs | 22 +++++++++++ 7 files changed, 113 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index cfc46075..bf736245 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1011,6 +1011,10 @@ pub struct Feature { /// The component the feature (and what it makes) belongs to; 0 = the root. #[serde(default, skip_serializing_if = "is_root")] pub component: u64, + /// For each picked edge (fillets, chamfers): where it sat in its body when picked, so it + /// is found again when earlier edits move or resize the body. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edge_refs: Vec, #[serde(flatten)] pub kind: FeatureKind, } @@ -1271,7 +1275,7 @@ impl Document { Some(n) if !n.trim().is_empty() => n.trim().to_string(), _ => self.unique_name(kind.base_name()), }; - let f = Feature { id, name, suppressed: false, body_names: Vec::new(), param_names: Vec::new(), component: 0, kind }; + let f = Feature { id, name, suppressed: false, body_names: Vec::new(), param_names: Vec::new(), component: 0, edge_refs: Vec::new(), kind }; match self.marker { Some(m) if m < self.features.len() => { self.features.insert(m, f); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 49474a0b..adc68f87 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -869,15 +869,71 @@ fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3) -> Vec3 { best.unwrap_or(p) } +/// Where a picked edge sat in its body: its direction (zero for curved edges) and the body's +/// bounding box at the time. +#[derive(Clone, Debug, PartialEq, Serialize, serde::Deserialize)] +pub struct EdgeRef { + pub dir: Vec3, + pub min: Vec3, + pub max: Vec3, +} + +fn edge_dir(e: &kernel::EdgeInfo) -> Vec3 { + let (Some(a), Some(b)) = (e.points.first(), e.points.last()) else { return Vec3::ZERO }; + let straight = e.points.iter().all(|p| p.dist_to_segment(*a, *b) < 1e-6 * (1.0 + e.length)); + if straight && a.dist(*b) > 1e-9 { (*b - *a).normalized().unwrap_or(Vec3::ZERO) } else { Vec3::ZERO } +} + +/// References for edges picked at `pts` on the body a blend would go on (the state before it). +pub fn edge_refs(st: &ModelState, body: &Option, pts: &[Vec3]) -> Vec { + let Ok(ti) = blend_target(st, body, pts) else { return Vec::new() }; + let Some(mb) = st.bodies.get(ti) else { return Vec::new() }; + let size = mb.body.size(); + let Ok(edges) = mb.body.edges((size * 1e-3).max(1e-3)) else { return Vec::new() }; + let bb = mb.mesh().bounds(); + pts.iter() + .map(|p| { + let e = edges.iter().min_by(|x, y| { + let d = |e: &kernel::EdgeInfo| e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); + d(x).total_cmp(&d(y)) + }); + EdgeRef { dir: e.map(edge_dir).unwrap_or(Vec3::ZERO), min: bb.min, max: bb.max } + }) + .collect() +} + /// Edge reference points re-found on the body: points on an edge stay; a point that moved off -/// (an upstream edit) goes to the nearest edge with a warning; points with no edge near are -/// dropped with a warning. -fn resolve_edges(b: &Body, pts: &[Vec3], warning: &mut Option) -> Result> { +/// (an upstream edit) is carried with the body (its place in the body's bounding box then and +/// now) to an edge running the same way; failing that it goes to the nearest edge with a +/// warning; points with no edge near are dropped with a warning. +fn resolve_edges(b: &Body, pts: &[Vec3], refs: &[EdgeRef], warning: &mut Option) -> Result> { let size = b.size(); let edges = b.edges((size * 1e-3).max(1e-3))?; let tight = (size * 2e-3).max(1e-3); + let dist = |p: Vec3, e: &kernel::EdgeInfo| e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); + let now = b.tessellate((size * 1e-2).max(1e-2)).map(|m| m.bounds()).ok(); let (mut out, mut moved, mut lost) = (Vec::new(), 0, 0); - for p in pts { + for (i, p) in pts.iter().enumerate() { + let on = edges.iter().any(|e| dist(*p, e) <= tight); + if !on && let (Some(r), Some(nb)) = (refs.get(i), now.as_ref()) { + // The same place in the body as it is now. + let map = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| { + if (a1 - a0).abs() > 1e-9 { b0 + (v - a0) * (b1 - b0) / (a1 - a0) } else { v + (b0 - a0) } + }; + let q = Vec3::new( + map(p.x, r.min.x, r.max.x, nb.min.x, nb.max.x), + map(p.y, r.min.y, r.max.y, nb.min.y, nb.max.y), + map(p.z, r.min.z, r.max.z, nb.min.z, nb.max.z), + ); + let fits = |e: &kernel::EdgeInfo| { + let d = edge_dir(e); + dist(q, e) <= tight && (r.dir.len() < 0.5 || d.cross(r.dir).len() < 1e-3) + }; + if edges.iter().any(fits) { + out.push(q); + continue; + } + } let best = edges .iter() .map(|e| (e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), e.mid)) @@ -1863,7 +1919,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let r = val(vals, radius, Kind::Length)?; let ti = blend_target(st, body, edges)?; let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) }; - let edges = resolve_edges(&mb.body, edges, warning)?; + let edges = resolve_edges(&mb.body, edges, &f.edge_refs, warning)?; let nb = if matches!(f.kind, FeatureKind::Fillet { .. }) { kernel::fillet(&mb.body, &edges, r)? } else { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 3cc62e32..e88401ff 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -710,7 +710,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, f: &F let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) }; let mut warn = None; - let edges = resolve_edges(&mb.body, &[*position + x * r0], &mut warn)?; + let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?; let nb = kernel::fillet(&mb.body, &edges, rf)?; if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::new(mb.name, nb, mb.feature); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fac43a33..fee92956 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -25,7 +25,9 @@ pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use document::*; -pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, parse_metric_thread, pattern_matrices, world_state}; +pub use eval::{ + EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_refs, parse_metric_thread, pattern_matrices, world_state, +}; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; pub use solvecraft_sketch as sketch; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cb879fdf..5675c224 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -182,6 +182,15 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, "edit sketch geometry with the sketch commands")); } + // New edges for a blend: remember where they sit now (in the model before the feature). + if let ( + FeatureKind::Fillet { edges: a, body, .. } | FeatureKind::Chamfer { edges: a, body, .. }, + FeatureKind::Fillet { edges: b, .. } | FeatureKind::Chamfer { edges: b, .. }, + ) = (&nf.kind, &f.kind) + && a != b + { + nf.edge_refs = solvecraft_doc::edge_refs(&s.model.state_before(id), body, a); + } if let Some(slot) = s.doc_mut().feature_mut(id) { *slot = nf; } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 4dd7d9f0..fb76fa18 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -243,7 +243,19 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu // Commands speak world coordinates; the feature is authored in the active component. let mut kind = kind; super::component::to_active_frame(s, &mut kind); + // Blends remember where their edges sat, to find them again after upstream edits. + let refs = match &kind { + FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => { + solvecraft_doc::edge_refs(&s.model.state(), body, edges) + } + _ => Vec::new(), + }; let id = s.doc_mut().add_feature(kind, name)?; + if !refs.is_empty() + && let Some(f) = s.doc_mut().feature_mut(id) + { + f.edge_refs = refs; + } s.doc_mut().name_feature_inputs(id); let comp = s.active_component; if comp != 0 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d127ca49..7a070755 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1144,3 +1144,25 @@ fn cached_evaluation_matches_from_scratch() { } } } + +/// A fillet keeps its edge when an earlier sketch dimension resizes the body (QA s06). +#[test] +fn fillet_follows_its_edge_after_an_upstream_resize() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"})); + let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})); + let bottom = r["curves"][0].as_str().unwrap_or_default().to_string(); + let d = run(&mut s, "SketchDimension", json!({"entities": [bottom], "value": 40})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 10})); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[40, 15, 10]], "radius": 2})); + let v = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0); + let strip = (4.0 - std::f64::consts::PI) * 30.0; + assert!((v(&mut s) - (12000.0 - strip)).abs() < 0.05, "{}", v(&mut s)); + let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into()); + run(&mut s, "ChangeParameterCommand", json!({"name": name, "expression": "60"})); + // The right top edge, now at x = 60: 18000 less the same 30 mm strip. + assert!((v(&mut s) - (18000.0 - strip)).abs() < 0.05, "{}", v(&mut s)); + let fillet = s.doc.features.last().map(|f| f.id).unwrap_or(0); + assert!(s.model.result(fillet).and_then(|r| r.warning.clone()).is_none(), "followed without a warning"); +}