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"); +}