From 2ec34c6e78ecba12f0e1ef60a9416593a02913d6 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 07:13:38 +0000 Subject: [PATCH] Sketches on body faces follow their face: the plane is a face reference found again on each evaluation (same normal, nearest the picked point), and face extrudes (Extrude on a face, Press Pull) use the linked projection of the face's edges instead of a frozen copy, so they follow when earlier features change Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 4 +++ crates/doc/src/document.rs | 8 +++++- crates/doc/src/eval.rs | 45 +++++++++++++++++++++++++++++++-- crates/doc/src/project.rs | 2 +- crates/engine/src/cmd/face.rs | 7 ++++- crates/engine/src/cmd/sketch.rs | 2 +- crates/engine/src/tests.rs | 11 ++++++++ crates/ui-egui/src/dialogs.rs | 2 +- 8 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 3bff7e1e..b8b2c1a7 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -132,6 +132,10 @@ fn map_plane(m: &Mat, p: &Plane) -> Plane { fn map_plane_ref(m: &Mat, p: &mut PlaneRef) { match p { PlaneRef::Custom { plane } => *plane = map_plane(m, plane), + PlaneRef::Face { plane, at } => { + *plane = map_plane(m, plane); + *at = apply_point(m, *at); + } PlaneRef::Offset { base, .. } => map_plane_ref(m, base), PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => { map_plane_ref(m, base); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 8b222c1b..8c00d203 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -43,6 +43,10 @@ pub enum PlaneRef { AtAngle { base: Box, axis_origin: Vec3, axis_dir: Vec3, angle: String }, /// A construction plane feature (by name). Construction { name: String }, + /// A planar body face: found again on each evaluation (the face with this plane's normal + /// nearest `at`), so sketches on it follow when earlier features change; `plane` is the + /// plane as picked (its frame, and the fallback when the face is gone). + Face { plane: Plane, at: Vec3 }, } /// Which closed profiles of a sketch a feature uses. @@ -1369,7 +1373,9 @@ impl Document { } match p { PlaneRef::Origin { name } => Plane::named(name).ok_or_else(|| DocError::Unknown(format!("plane `{name}`"))), - PlaneRef::Custom { plane } => Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into())), + PlaneRef::Custom { plane } | PlaneRef::Face { plane, .. } => { + Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into())) + } PlaneRef::Offset { base, distance } => { let b = self.resolve_plane(vals, base, depth + 1)?; Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?)) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 741cabbf..108fcecb 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -10,7 +10,7 @@ use solvecraft_kernel::{self as kernel, Body, BoolOp}; use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve}; use crate::expr::{self, Kind, Value}; -use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, ProfileSel, Result}; +use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel, Result}; /// A body in the evaluated model. #[derive(Clone, Debug)] @@ -1399,6 +1399,41 @@ fn replay(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut #[path = "eval_more.rs"] mod more; +/// The plane of the body face a sketch sits on, found again: among planar faces with the +/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it. +fn face_plane(st: &ModelState, picked: &Plane, at: Vec3) -> (Plane, Option) { + let n = picked.normal(); + let mut best: Option<(f64, f64)> = None; // (distance from `at`, plane offset along n) + for b in &st.bodies { + let tol = (b.body.size() * 1e-3).max(1e-3); + let (Ok(faces), Ok(mesh)) = (b.body.faces(tol), b.body.tessellate(tol)) else { continue }; + for f in faces { + let Some(fnrm) = f.plane_normal else { continue }; + if fnrm.dot(n) < 1.0 - 1e-6 { + continue; + } + let d = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter(|(_, tf)| **tf as usize == f.index) + .filter_map(|(t, _)| mesh.tri(t)) + .map(|[a, bb, c]| crate::project::closest_on_triangle(at, a, bb, c).dist(at)) + .fold(f64::INFINITY, f64::min); + if best.is_none_or(|x| d < x.0) { + best = Some((d, f.centroid.dot(n))); + } + } + } + match best { + Some((_, off)) => { + let shift = off - picked.origin.dot(n); + (picked.offset(shift), None) + } + None => (*picked, Some("the face the sketch was on is gone; the sketch stays where it was".into())), + } +} + fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { match &f.kind { FeatureKind::SheetBase { .. } @@ -1416,9 +1451,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Align { .. } | FeatureKind::Remove { .. } => more::eval(doc, vals, f, st), FeatureKind::Sketch { plane, sketch } => { - let plane = doc.resolve_plane(vals, plane, 0)?; + let (plane, face_warning) = match plane { + PlaneRef::Face { plane: picked, at } => face_plane(st, picked, *at), + other => (doc.resolve_plane(vals, other, 0)?, None), + }; let mut sk = sketch.clone(); let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk); + if let Some(w) = face_warning { + warns.insert(0, w); + } doc.apply_dimension_values(vals, &mut sk)?; let report = solve(&mut sk); if !report.ok() { diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 67e9cf67..be0c36de 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -347,7 +347,7 @@ pub(crate) fn nearest_edge(m: &Mesh, p: Vec3) -> Option<(usize, Vec3, f64)> { best } -fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { +pub(crate) fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { let n = (b - a).cross(c - a); let Some(nn) = n.normalized() else { return closest_on_segment(p, a, b) }; let q = p - nn * (p - a).dot(nn); diff --git a/crates/engine/src/cmd/face.rs b/crates/engine/src/cmd/face.rs index 06381764..9ac66c72 100644 --- a/crates/engine/src/cmd/face.rs +++ b/crates/engine/src/cmd/face.rs @@ -99,10 +99,15 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result< let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?; (spec.run)(s, &p) }; - let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}}))?; + let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?; let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; s.refresh(); let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; + // The face's edges came in as linked projections: they follow the face when earlier + // features change, so nothing is copied. + if r.get("projected").is_some_and(|x| !x.is_null()) { + return Ok((sid, plane.to_local(inside))); + } for e in &edges { let pts: Vec = e.iter().map(|p| plane.to_local(*p)).collect(); let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue }; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index f1bf1b91..0d78ac1a 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -318,7 +318,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { } } let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?; - PlaneRef::Custom { plane: pl } + PlaneRef::Face { plane: pl, at: fp } } else { let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2b256b0b..48fbdaf0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -977,3 +977,14 @@ fn components_export_as_step_assembly() { assert_eq!(xs.len(), 2); assert!(((xs[0] - xs[1]).abs() - 20.0).abs() < 1e-6, "{xs:?}"); } + +/// A face extrude follows its face when earlier features change. +#[test] +fn face_extrude_follows_edits() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20, "name": "Base"})); + run(&mut s, "Extrude", json!({"face": [20, 15, 20], "distance": 10, "operation": "join", "name": "Pad"})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 30.0) < 1e-6); + run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25", "length": "50"}})); + assert!(rel(volume(&mut s), 50.0 * 30.0 * 35.0) < 1e-6, "{}", volume(&mut s)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 57c67d25..b5479418 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2097,7 +2097,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option { fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }), - PlaneRef::Custom { plane } => face_sel(s, plane.origin), + PlaneRef::Custom { plane } | PlaneRef::Face { plane, .. } => face_sel(s, plane.origin), _ => None, } }