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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 07:42:31 +00:00
1 parent 2db9e66048
commit 2ec34c6e78
8 files changed
+74 -7

No files matched your search

+4
View File
@@ -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);
+7 -1
View File
@@ -43,6 +43,10 @@ pub enum PlaneRef {
AtAngle { base: Box<PlaneRef>, 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)?))
+43 -2
View File
@@ -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<String, Value>, 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<String>) {
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<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::SheetBase { .. }
@@ -1416,9 +1451,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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() {
+1 -1
View File
@@ -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);
+6 -1
View File
@@ -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<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue };
+1 -1
View File
@@ -318,7 +318,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
}
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"))?;
+11
View File
@@ -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));
}
+1 -1
View File
@@ -2097,7 +2097,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
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,
}
}