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:
1 parent
2db9e66048
commit
2ec34c6e78
8 files changed
+74
-7
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user