Merge upstream profile picks and flush-pad fixes with adaptive identities

This commit is contained in:
bherbruck committed 2026-10-11 09:43:16 -04:00
commit 0167c358bb
15 files changed
+623 -44

No files matched your search

+28
View File
@@ -195,6 +195,29 @@ 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)
}
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
/// the name and suppression, which timeline.rename and timeline.suppress change.
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
@@ -247,6 +270,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
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;
@@ -273,6 +297,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());
+46 -30
View File
@@ -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")
@@ -204,31 +204,54 @@ 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::profile_at_point;
let st = s.model.state();
'sels: for (sketch, sel) in kind.profile_sels_mut() {
for (sketch, sel) in kind.profile_sels_mut() {
let Some(ss) = st.sketch(sketch) else { continue };
if !matches!(
sel,
ProfileSel::Indices { .. } | ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }
) {
continue;
}
let indices = solvecraft_doc::profile_selection_indices(ss, sel)?;
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 !matches!(sel, ProfileSel::WholeBoundaries { .. })
&& (pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1)
{
continue 'sels;
let indices = match sel {
ProfileSel::Indices { .. } => solvecraft_doc::profile_selection_indices(ss, sel)?,
ProfileSel::Points { points } => {
let Some(indices) = points.iter().map(|point| profile_at_point(&ss.profiles, *point)).collect::<Option<Vec<_>>>() else { continue };
indices
}
}
ProfileSel::Curves { loops } => {
// The command's ordinary curve lists are sets, including repeated
// caller IDs. Saved graph-span multisets retain their legacy meaning.
// Reserved offset roles never use this ordinary normalization.
let mut normalized = loops.clone();
let reserved = ss
.sketch
.offsets
.iter()
.filter_map(|offset| offset.topology.as_ref())
.flat_map(|topology| topology.roles.iter().map(|role| role.output.as_str()))
.collect::<std::collections::BTreeSet<_>>();
for curves in &mut normalized {
let dynamic = curves.iter().any(|curve| reserved.contains(curve.as_str()));
if !dynamic {
let mut seen = std::collections::BTreeSet::new();
curves.retain(|curve| seen.insert(curve.clone()));
}
}
let selection = ProfileSel::Curves { loops: normalized };
solvecraft_doc::profile_selection_indices(ss, &selection)?;
*sel = selection;
continue;
}
ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
// Explicit identities retain the caller's semantics. Validate through the
// same resolver as recomputation, including reserved-role precedence.
solvecraft_doc::profile_selection_indices(ss, sel)?;
continue;
}
ProfileSel::All => continue,
};
let captured = solvecraft_doc::capture_profile_selection(ss, &indices)?;
*sel = match captured {
ProfileSel::OffsetBoundaries { associations }
@@ -260,13 +283,6 @@ fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
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 {
+2
View File
@@ -638,6 +638,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;
+206
View File
@@ -0,0 +1,206 @@
//! 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));
}
/// New explicit API lists are sets even when reordered or repeated. Their saved
/// form must not accidentally become a legacy graph-span multiset.
#[test]
fn explicit_curve_pick_normalizes_repeats_before_save_and_parameter_edit() {
let mut s = tray();
run(&mut s, "solid.extrude", json!({"sketch":"Tray","profiles":[{"curves":["top","bottom","div","left","bottom","top"]}],"distance":10}));
let ProfileSel::Curves { loops } = extrude_kind(&s) else { panic!("{:?}", extrude_kind(&s)) };
assert_eq!(loops, vec![vec!["top", "bottom", "div", "left"]]);
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
run(&mut reopened, "parameters.change", json!({"name":"split","expression":"20 mm"}));
assert_eq!(last_error(&reopened), None);
assert!((volumes(&reopened)[0] - 4000.0).abs() < 1.0);
}
#[test]
fn repeated_explicit_curve_ids_do_not_count_split_graph_spans() {
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!({}));
assert_eq!(s.model.state().sketches[0].profiles[0].outer_curves.iter().filter(|id| *id == "a").count(), 2);
run(&mut s, "solid.extrude", json!({"sketch":"S","profiles":[{"curves":["d","a","a","a","c","b"]}],"distance":2}));
let ProfileSel::Curves { loops } = extrude_kind(&s) else { panic!("{:?}", extrude_kind(&s)) };
assert_eq!(loops, vec![vec!["d", "a", "c", "b"]]);
let reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
assert_eq!(last_error(&reopened), None);
assert!((volumes(&reopened)[0] - 200.0).abs() < 1e-3);
}
/// 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));
}