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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 5b9749aad6
commit 99385b1e31
7 files changed
+113 -8

No files matched your search

+5 -1
View File
@@ -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<crate::EdgeRef>,
#[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);
+61 -5
View File
@@ -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<String>, pts: &[Vec3]) -> Vec<EdgeRef> {
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<String>) -> Result<Vec<Vec3>> {
/// (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<String>) -> Result<Vec<Vec3>> {
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<String, Value>, 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 {
+1 -1
View File
@@ -710,7 +710,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, 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);
+3 -1
View File
@@ -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;
+9
View File
@@ -182,6 +182,15 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
{
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;
}
+12
View File
@@ -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
+22
View File
@@ -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");
}