diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 6fb8c4c0..58c8914e 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -79,8 +79,10 @@ pub enum ProfileSel { All, /// Profiles by index in the solved sketch's profile list. Indices { indices: Vec }, - /// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable - /// across parameter changes. + /// Profiles whose outer loop is made of exactly these curves, or pieces of them where other + /// curves cut them (order and repeats don't matter; see `sketch::profile_by_curves`). Found + /// again on every evaluation, so stable across parameter changes; no match or several is + /// an error, never another profile. Curves { loops: Vec> }, /// The profile containing each sketch point. Points { points: Vec }, diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b44ede63..4c832d24 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -784,14 +784,8 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result { let mut v = Vec::new(); for l in loops { - let mut want: Vec<&String> = l.iter().collect(); - want.sort(); - let found = ps.iter().find(|p| { - let mut have: Vec<&String> = p.outer_curves.iter().collect(); - have.sort(); - have == want - }); - v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone()); + let i = solvecraft_sketch::profile_by_curves(ps, l).map_err(|e| curve_pick_error(e, l, &ss.name))?; + v.push(ps.get(i).ok_or_else(|| DocError::Unknown(format!("profile {i} of {}", ss.name)))?.region.clone()); } v } @@ -799,7 +793,7 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result Result DocError { + match e { + solvecraft_sketch::CurvePick::Missing => DocError::Invalid(format!( + "no profile of {sketch} is bounded by exactly the curves {ids:?} (a curve was deleted, or another curve now cuts the region)" + )), + solvecraft_sketch::CurvePick::Ambiguous(n) => DocError::Invalid(format!( + "{n} profiles of {sketch} are bounded by exactly the curves {ids:?}; add a curve that tells them apart, or pick by index or point" + )), + } +} + fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String { if let Some(n) = f.body_names.get(k) && !n.is_empty() diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 02e849a1..a39b7eda 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -34,9 +34,9 @@ pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid}; pub use document::*; pub use eval::{ - EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, - parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame, state_in_frame_at, - through_all_distance, world_state, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs, + object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame, + state_in_frame_at, through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f18010df..cbcfff75 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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 { + 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 { 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 { 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()); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 755fec72..fb3a8b54 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { .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 = 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::>()?, + ProfileSel::Points { points } => { + let Some(v) = points.iter().map(|q| profile_at_point(&ss.profiles, *q)).collect::>>() 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 { let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) }; match v { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1d157477..59c72e92 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/profile_curves_tests.rs b/crates/engine/src/profile_curves_tests.rs new file mode 100644 index 00000000..23453ee0 --- /dev/null +++ b/crates/engine/src/profile_curves_tests.rs @@ -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 { + s.model.state().bodies.iter().map(|b| b.body.tessellate(1e-2).unwrap().measure().volume).collect() +} + +fn last_error(s: &Session) -> Option { + 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)); +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index e48cccdd..d81543de 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -32,7 +32,7 @@ pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView}; pub use offset::OffsetChain; pub use periodic::periodic_beziers; -pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; +pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves}; pub use shape::{Shape, intersections}; pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding}; pub use text::{MAX_TEXT_CHARS, text_geometry}; diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index a2ce3596..67d97888 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -829,6 +829,69 @@ pub fn find_profiles(sk: &Sketch) -> Vec { out } +/// Why no single profile is bounded by a list of curves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CurvePick { + /// No profile's outer loop is made of exactly those curves. + Missing, + /// This many profiles are (e.g. both halves of a circle cut by a line). + Ambiguous(usize), +} + +/// The profile picked by the curves around it, by index into `profiles`. A profile matches when +/// its outer loop is made of the listed curves and no others: every listed curve, or a piece of +/// it where other curves cut it, lies on the loop, and every piece of the loop belongs to a +/// listed curve. Order and repeats don't matter. Holes don't take part (islands inside a profile +/// are its holes, as for a profile picked by a point). When several profiles match, one whose +/// loop has exactly the listed pieces (a curve listed twice for two pieces) wins; otherwise the +/// pick is ambiguous, never a guess. +pub fn profile_by_curves(profiles: &[Profile], ids: &[String]) -> Result { + use std::collections::BTreeSet; + let want: BTreeSet<&str> = ids.iter().map(String::as_str).collect(); + if want.is_empty() { + return Err(CurvePick::Missing); + } + let hits: Vec = profiles + .iter() + .enumerate() + .filter(|(_, p)| p.outer_curves.iter().map(String::as_str).collect::>() == want) + .map(|(i, _)| i) + .collect(); + match hits.as_slice() { + [] => Err(CurvePick::Missing), + [i] => Ok(*i), + _ => { + fn sorted(v: &[String]) -> Vec<&str> { + let mut v: Vec<&str> = v.iter().map(String::as_str).collect(); + v.sort_unstable(); + v + } + let exact = sorted(ids); + let same: Vec = hits.iter().copied().filter(|i| profiles.get(*i).is_some_and(|p| sorted(&p.outer_curves) == exact)).collect(); + match same.as_slice() { + [i] => Ok(*i), + _ => Err(CurvePick::Ambiguous(hits.len())), + } + } + } +} + +/// The innermost profile containing a sketch point, by index into `profiles`. +pub fn profile_at_point(profiles: &[Profile], p: Vec2) -> Option { + profiles.iter().enumerate().filter(|(_, q)| q.region.contains(p)).min_by(|a, b| a.1.area.total_cmp(&b.1.area)).map(|(i, _)| i) +} + +/// The distinct curve ids of a loop, in loop order (a curve cut into pieces is listed once). +pub fn distinct_curves(ids: &[String]) -> Vec { + let mut out: Vec = Vec::with_capacity(ids.len()); + for id in ids { + if !out.contains(id) { + out.push(id.clone()); + } + } + out +} + fn same_seg(a: &Seg2, b: &Seg2, tol: f64) -> bool { match (*a, *b) { (Seg2::Line { a: p, b: q }, Seg2::Line { a: r, b: s }) => p.dist(r) <= tol && q.dist(s) <= tol, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2e6caa23..c0fc7ec0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2923,19 +2923,8 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P match sel { ProfileSel::All => (0..ps.len()).collect(), ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < ps.len()).collect(), - ProfileSel::Curves { loops } => loops - .iter() - .filter_map(|l| { - let mut want: Vec<&String> = l.iter().collect(); - want.sort(); - ps.iter().position(|p| { - let mut have: Vec<&String> = p.outer_curves.iter().collect(); - have.sort(); - have == want - }) - }) - .collect(), - ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(), + ProfileSel::Curves { loops } => loops.iter().filter_map(|l| solvecraft_engine::doc::sketch::profile_by_curves(ps, l).ok()).collect(), + ProfileSel::Points { points } => points.iter().filter_map(|q| solvecraft_engine::doc::sketch::profile_at_point(ps, *q)).collect(), } } diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 7b4e0ce7..72dc0997 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -83,3 +83,12 @@ constraint while you drag. Closed loops become profiles automatically, including regions split by crossing lines and islands inside outlines. Extrude, revolve, sweep and loft pick profiles. + +A feature remembers a picked profile by the curves around it, not by where it was clicked: +`"profiles": [{"curves": ["l1", "l2", "a1", "l3"]}]` (or `[{"curves": ["c1"]}]` for a circle) +picks the region whose outer loop is made of exactly those curves, or the pieces of them that +other curves cut off. It is found again on every recompute, so the feature keeps the same region +when a parameter moves the geometry. Islands inside the region are its holes. When no region, or +more than one (both halves of a circle cut by a line), is bounded by exactly those curves, the +feature reports an error rather than taking another region. Profiles picked by index or by point +(`[{"point": [x, y]}]`) are stored the same way when the feature is made.