feat: pick sketch profiles by their bounding curve ids so features keep the same region when parameters move the geometry, matching curves cut into pieces, refusing ambiguous or missing picks instead of taking another region, and storing point picks by curves
This commit is contained in:
1 parent
22672585c9
commit
848e7aec0e
11 files changed
+342
-60
No files matched your search
@@ -188,12 +188,36 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `profiles` (also a loft section's) given as the feature commands take it (`"all"`,
|
||||
/// `[index]`, `[{curves}]`, `[{point}]`...) becomes the stored form; the stored form passes.
|
||||
fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
let conv = |v: &mut Value| -> Result<()> {
|
||||
if let Some(pv) = v.get("profiles").filter(|x| x.get("type").is_none()) {
|
||||
let sel = super::features::profiles(&json!({"profiles": pv}), cmd)?;
|
||||
let sv = serde_json::to_value(sel).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("profiles".into(), sv);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut set = set.clone();
|
||||
conv(&mut set)?;
|
||||
if let Some(secs) = set.get_mut("sections").and_then(Value::as_array_mut) {
|
||||
for sec in secs.iter_mut().take(10_000) {
|
||||
conv(sec)?;
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?;
|
||||
let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let set = &command_profiles(set, cmd)?;
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
@@ -216,6 +240,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_refs = solvecraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solvecraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
}
|
||||
// New face points: their faces' names now.
|
||||
if nf.kind.face_points() != f.kind.face_points() {
|
||||
nf.face_names = solvecraft_doc::naming::face_names_at(&s.model.state_before(id), &nf.kind.face_points());
|
||||
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -124,18 +124,7 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||
ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(),
|
||||
ProfileSel::Points { points } => *points.first()?,
|
||||
ProfileSel::Curves { loops } => {
|
||||
let mut want: Vec<&String> = loops.first()?.iter().collect();
|
||||
want.sort();
|
||||
ps.iter()
|
||||
.find(|x| {
|
||||
let mut have: Vec<&String> = x.outer_curves.iter().collect();
|
||||
have.sort();
|
||||
have == want
|
||||
})?
|
||||
.region
|
||||
.interior_point()
|
||||
}
|
||||
ProfileSel::Curves { loops } => ps.get(solvecraft_doc::sketch::profile_by_curves(ps, loops.first()?).ok()?)?.region.interior_point(),
|
||||
};
|
||||
Some((ss.plane.to_world(q), ss.plane.normal()))
|
||||
}
|
||||
@@ -212,37 +201,55 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
|
||||
}
|
||||
|
||||
/// Profile indices become the curve ids of each profile's loops as the sketch is now solved.
|
||||
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
/// Profiles picked by index or point are stored by the curve ids of each profile's outer loop
|
||||
/// as the sketch is now solved, so they follow the geometry when parameters move it. A pick
|
||||
/// whose curves would not find that same profile again (two profiles bounded by the same
|
||||
/// curves) stays as given. Curve picks are checked: one that names no profile, or several, is
|
||||
/// refused now rather than failing later.
|
||||
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
use solvecraft_doc::sketch::{distinct_curves, profile_at_point, profile_by_curves};
|
||||
let st = s.model.state();
|
||||
'sels: for (sketch, sel) in kind.profile_sels_mut() {
|
||||
let ProfileSel::Indices { indices } = sel else { continue };
|
||||
let Some(ss) = st.sketch(sketch) else { continue };
|
||||
let mut loops = Vec::with_capacity(indices.len());
|
||||
for i in indices.iter() {
|
||||
let pr = ss
|
||||
.profiles
|
||||
.get(*i)
|
||||
.ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?;
|
||||
// Two profiles with the same outer curves (one inside the other's holes cannot be):
|
||||
// keep the index then.
|
||||
if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 {
|
||||
let picked: Vec<usize> = match sel {
|
||||
ProfileSel::Indices { indices } => indices
|
||||
.iter()
|
||||
.map(|i| {
|
||||
if *i < ss.profiles.len() {
|
||||
Ok(*i)
|
||||
} else {
|
||||
Err(EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))
|
||||
}
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
ProfileSel::Points { points } => {
|
||||
let Some(v) = points.iter().map(|q| profile_at_point(&ss.profiles, *q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
v
|
||||
}
|
||||
ProfileSel::Curves { loops } => {
|
||||
for l in loops.iter() {
|
||||
profile_by_curves(&ss.profiles, l)
|
||||
.map_err(|e| EngineError::Other(solvecraft_doc::curve_pick_error(e, l, &ss.name).to_string()))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ProfileSel::All => continue,
|
||||
};
|
||||
let mut loops = Vec::with_capacity(picked.len());
|
||||
for i in picked {
|
||||
let Some(pr) = ss.profiles.get(i) else { continue 'sels };
|
||||
let ids = distinct_curves(&pr.outer_curves);
|
||||
// Keep the pick as given when its curves don't lead back to this profile.
|
||||
if ids.is_empty() || profile_by_curves(&ss.profiles, &ids) != Ok(i) {
|
||||
continue 'sels;
|
||||
}
|
||||
loops.push(pr.outer_curves.clone());
|
||||
loops.push(ids);
|
||||
}
|
||||
*sel = ProfileSel::Curves { loops };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_loop(a: &[String], b: &[String]) -> bool {
|
||||
let (mut a, mut b) = (a.to_vec(), b.to_vec());
|
||||
a.sort();
|
||||
b.sort();
|
||||
a == b
|
||||
}
|
||||
|
||||
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
|
||||
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
|
||||
match v {
|
||||
|
||||
@@ -550,6 +550,8 @@ mod extent_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_curves_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_refs_tests;
|
||||
#[cfg(test)]
|
||||
mod split_face_tests;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Profiles picked by the curves around them stay the same region when a parameter moves the
|
||||
//! sketch, where a pick by point can land in another region.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use solvecraft_doc::{FeatureKind, ProfileSel};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volumes(s: &Session) -> Vec<f64> {
|
||||
s.model.state().bodies.iter().map(|b| b.body.tessellate(1e-2).unwrap().measure().volume).collect()
|
||||
}
|
||||
|
||||
fn last_error(s: &Session) -> Option<String> {
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
s.model.result(id).and_then(|r| r.error.clone())
|
||||
}
|
||||
|
||||
/// A 60 × 20 tray split in two by a divider `split` mm from its left side: the left bay is
|
||||
/// bounded by the left side, the divider and the pieces of the bottom and top the divider cuts.
|
||||
fn tray() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "split", "expression": "30 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tray"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [60, 0], [60, 20], [0, 20]], "closed": true, "ids": ["bottom", "right", "top", "left"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["bottom", "right", "top", "left"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[30, 0], [30, 20]], "ids": ["div"]}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "div.start", "b": "bottom"}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "div.end", "b": "top"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "div"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["left", "div"], "value": "split"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
fn extrude_kind(s: &Session) -> ProfileSel {
|
||||
match &s.doc.features.last().unwrap().kind {
|
||||
FeatureKind::Extrude { profiles, .. } => profiles.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's case: the left bay picked by its curves keeps extruding the left bay when
|
||||
/// `split` moves the divider past the old pick point; a pick by that point (as a design saved
|
||||
/// before picks were stored by curves holds it) silently extrudes the right bay instead.
|
||||
#[test]
|
||||
fn a_profile_picked_by_curves_follows_a_parameter_change() {
|
||||
let mut s = tray();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Tray", "profiles": [{"curves": ["left", "bottom", "div", "top"]}], "distance": 10}));
|
||||
let v = volumes(&s);
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!((v[0] - 30.0 * 20.0 * 10.0).abs() < 1.0, "{v:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
|
||||
assert_eq!(last_error(&s), None);
|
||||
let v = volumes(&s);
|
||||
assert!((v[0] - 20.0 * 20.0 * 10.0).abs() < 1.0, "still the left bay, now 20 wide: {v:?}");
|
||||
|
||||
// The same bay picked by a point (25, 10), stored as a point.
|
||||
let mut s = tray();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Tray", "profiles": [{"point": [25, 10]}], "distance": 10}));
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id)
|
||||
&& let FeatureKind::Extrude { profiles, .. } = &mut f.kind
|
||||
{
|
||||
*profiles = ProfileSel::Points { points: vec![solvecraft_geom::Vec2::new(25.0, 10.0)] };
|
||||
}
|
||||
s.refresh();
|
||||
assert!((volumes(&s)[0] - 6000.0).abs() < 1.0);
|
||||
run(&mut s, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
|
||||
let v = volumes(&s);
|
||||
assert!((v[0] - 40.0 * 20.0 * 10.0).abs() < 1.0, "the point now sits in the right bay: {v:?}");
|
||||
}
|
||||
|
||||
/// A pick by point is stored by the curves around the picked profile, so it follows too.
|
||||
#[test]
|
||||
fn a_point_pick_is_stored_by_curves() {
|
||||
let mut s = tray();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Tray", "profiles": [{"point": [25, 10]}], "distance": 10}));
|
||||
let ProfileSel::Curves { loops } = extrude_kind(&s) else { panic!("{:?}", extrude_kind(&s)) };
|
||||
let mut ids = loops[0].clone();
|
||||
ids.sort();
|
||||
assert_eq!(ids, ["bottom", "div", "left", "top"]);
|
||||
run(&mut s, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
|
||||
assert_eq!(last_error(&s), None);
|
||||
assert!((volumes(&s)[0] - 4000.0).abs() < 1.0, "{:?}", volumes(&s));
|
||||
}
|
||||
|
||||
/// Curves cut into several pieces of one loop still name it: a line ending on the square's
|
||||
/// bottom from outside cuts the bottom in two, both pieces on the square's loop.
|
||||
#[test]
|
||||
fn a_curve_cut_into_pieces_still_names_its_profile() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true, "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, -5], [5, 0]], "ids": ["stub"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let ps = &s.model.state().sketches[0].profiles;
|
||||
assert_eq!(ps.len(), 1);
|
||||
assert_eq!(ps[0].outer_curves.iter().filter(|c| *c == "a").count(), 2, "the bottom is in two pieces: {:?}", ps[0].outer_curves);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["a", "b", "c", "d"]}], "distance": 2}));
|
||||
assert_eq!(last_error(&s), None);
|
||||
assert!((volumes(&s)[0] - 200.0).abs() < 1e-3, "{:?}", volumes(&s));
|
||||
}
|
||||
|
||||
/// Both halves of a circle cut by a line are bounded by the same two curves: an error, not one
|
||||
/// of them. A list no profile matches is an error too.
|
||||
#[test]
|
||||
fn ambiguous_and_missing_curve_picks_are_errors() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "ring"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]], "ids": ["cut"]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [40, 0], "radius": 3, "id": "dot"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.extrude", &json!({"sketch": "S", "profiles": [{"curves": ["ring", "cut"]}], "distance": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("2 profiles"), "{e}");
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
let e = s.execute("solid.extrude", &json!({"sketch": "S", "profiles": [{"curves": ["ring"]}], "distance": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no profile"), "{e}");
|
||||
let e = s.execute("solid.extrude", &json!({"sketch": "S", "profiles": [{"curves": ["dot", "cut"]}], "distance": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no profile"), "{e}");
|
||||
// A closed curve alone.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["dot"]}], "distance": 2}));
|
||||
assert!((volumes(&s)[0] - std::f64::consts::PI * 9.0 * 2.0).abs() < 0.5, "{:?}", volumes(&s));
|
||||
}
|
||||
|
||||
/// An island inside the picked outline is its hole, as with a pick by point; the island is a
|
||||
/// profile of its own, picked by its curve.
|
||||
#[test]
|
||||
fn a_curve_pick_keeps_islands_as_holes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true, "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5, "id": "hole"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["a", "b", "c", "d"]}], "distance": 1}));
|
||||
let disc = std::f64::consts::PI * 25.0;
|
||||
assert!((volumes(&s)[0] - (400.0 - disc)).abs() < 0.5, "{:?}", volumes(&s));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["hole"]}], "distance": 1, "operation": "new"}));
|
||||
assert!((volumes(&s)[1] - disc).abs() < 0.5, "{:?}", volumes(&s));
|
||||
}
|
||||
|
||||
/// Curve picks are saved in the design file and read back; timeline.edit takes the command's
|
||||
/// `profiles` form.
|
||||
#[test]
|
||||
fn curve_picks_save_load_and_edit() {
|
||||
let mut s = tray();
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Tray", "profiles": [{"curves": ["left", "bottom", "div", "top"]}], "distance": 10, "name": "Bay"}),
|
||||
);
|
||||
let text = serde_json::to_string(&*s.doc).unwrap();
|
||||
let doc = solvecraft_doc::format::read(&text).unwrap();
|
||||
let mut t = Session::new(doc);
|
||||
assert_eq!(extrude_kind(&t), extrude_kind(&s));
|
||||
run(&mut t, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
|
||||
assert!((volumes(&t)[0] - 4000.0).abs() < 1.0, "{:?}", volumes(&t));
|
||||
// Switch to the right bay through timeline.edit.
|
||||
run(&mut t, "timeline.edit", json!({"feature": "Bay", "set": {"profiles": [{"curves": ["div", "bottom", "right", "top"]}]}}));
|
||||
assert!((volumes(&t)[0] - 8000.0).abs() < 1.0, "{:?}", volumes(&t));
|
||||
run(&mut t, "timeline.edit", json!({"feature": "Bay", "set": {"profiles": [{"point": [5, 5]}]}}));
|
||||
assert!(matches!(extrude_kind(&t), ProfileSel::Curves { .. }), "{:?}", extrude_kind(&t));
|
||||
assert!((volumes(&t)[0] - 4000.0).abs() < 1.0, "{:?}", volumes(&t));
|
||||
// An old design's point pick still loads and evaluates.
|
||||
let old = text.replace(r#"{"type":"curves","loops":[["left","bottom","div","top"]]}"#, r#"{"type":"points","points":[[25.0,10.0]]}"#);
|
||||
assert_ne!(old, text);
|
||||
let t = Session::new(solvecraft_doc::format::read(&old).unwrap());
|
||||
assert!((volumes(&t)[0] - 6000.0).abs() < 1.0, "{:?}", volumes(&t));
|
||||
}
|
||||
Reference in new issue
Block a user