diff --git a/crates/constraints/src/auto.rs b/crates/constraints/src/auto.rs index c6754f2..d33185c 100644 --- a/crates/constraints/src/auto.rs +++ b/crates/constraints/src/auto.rs @@ -1,4 +1,5 @@ -//! AUTOCONSTRAIN: infer constraints from geometry that already nearly satisfies them. +//! AUTOCONSTRAIN: infer constraints from geometry that already nearly satisfies them; and +//! CONSTRAINTINFER's inference for what a command just placed ([`infer_placed`]). use cadcraft_doc::{Constraint, ConstraintKind, Drawing, EntityKind, GeomRef, Handle, Sub}; use cadcraft_geom::{Line, Vec2}; @@ -191,6 +192,114 @@ pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec Vec { + let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.0 }; + let mut all: Vec = placed.iter().take(MAX_OBJECTS).copied().collect(); + for h in others { + if all.len() >= MAX_OBJECTS { + break; + } + if !all.contains(h) { + all.push(*h); + } + } + let mine = |c: &Constraint| c.refs.iter().any(|r| placed.contains(&r.handle)); + let mut out: Vec = infer(d, &all, o).into_iter().filter(|c| INFER_TYPES.contains(&c.kind) && mine(c)).collect(); + // Placed end points on other objects' midpoints, centres, or on the objects themselves. + let allow = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Coincident.name())); + if !allow { + return out; + } + let g = collect(d, &all); + // Point to point (midpoint, centre) first; point on an object last, so a tangent or + // perpendicular snap keeps its tangency (an end on the curve it touches adds nothing then). + let mut extra = Vec::new(); + let mut on = Vec::new(); + for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) { + if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) { + continue; + } + let target = g + .lines + .iter() + .filter(|(r, _, _)| r.handle != rp.handle) + .find_map(|(r, a, b)| { + let ln = Line::new(*a, *b); + if a.mid(*b).dist(*p) <= tol && r.sub == Sub::Whole { + Some(GeomRef::new(r.handle, Sub::Mid)) + } else { + let t = ln.param_of(*p); + (ln.dist(*p) <= tol && (0.0..=1.0).contains(&t) && ln.len() > tol).then_some(*r) + } + }) + .or_else(|| { + g.curves.iter().filter(|(r, _, _)| r.handle != rp.handle).find_map(|(r, c, rad)| { + if c.dist(*p) <= tol { + Some(GeomRef::new(r.handle, Sub::Center)) + } else { + ((c.dist(*p) - rad).abs() <= tol && on_arc(d, r.handle, *c, *p)).then_some(*r) + } + }) + }); + if let Some(t) = target + && extra.len() + on.len() + out.len() < MAX_CANDIDATES + { + let c = Constraint { id: 0, kind: ConstraintKind::Coincident, refs: vec![*rp, t], name: String::new(), expr: String::new() }; + if t.sub == Sub::Whole { on.push(c) } else { extra.push(c) } + } + } + // Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line). + let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 }; + let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name())); + for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) { + for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) { + let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2)); + let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p)); + let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p)); + let ang = ang_between(*b1 - *a1, *b2 - *a2); + let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2)); + if allow_per + && tee + && !dup + && (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol + && l1.len() > tol + && l2.len() > tol + && out.len() < MAX_CANDIDATES + { + out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() }); + } + } + } + let n = out.iter().take_while(|c| c.kind == ConstraintKind::Coincident).count(); + out.splice(n..n, extra); + out.extend(on); + out +} + +/// Does `p` (on the circle) lie within the arc's sweep (always for a circle)? +fn on_arc(d: &Drawing, h: Handle, c: Vec2, p: Vec2) -> bool { + match d.entity(h).map(|e| &e.kind) { + Some(EntityKind::Arc(a)) => cadcraft_geom::angle_in_sweep((p - c).angle(), a.start, a.end), + _ => true, + } +} + /// Infer and apply constraints over `handles`; returns the ids added. Redundant or conflicting /// candidates are skipped. pub fn autoconstrain(d: &mut Drawing, handles: &[Handle], o: &InferOptions) -> Vec { diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index b4d3be4..ecbdc2e 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -36,7 +36,7 @@ use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle}; use cadcraft_geom::Vec2; use serde::Serialize; -pub use auto::{InferOptions, autoconstrain, infer}; +pub use auto::{INFER_TYPES, InferOptions, autoconstrain, infer, infer_placed}; pub use dof::{DofReport, DofStatus, ObjectDof, dof}; pub use expr::{Env, ExprError}; pub use model::{ref_points, rigid_points, supported}; diff --git a/crates/engine/src/cmd/constraints.rs b/crates/engine/src/cmd/constraints.rs index d88bf7b..fd562f2 100644 --- a/crates/engine/src/cmd/constraints.rs +++ b/crates/engine/src/cmd/constraints.rs @@ -98,6 +98,9 @@ pub fn specs() -> Vec { .menu(&["Tools", "Parametric", "Constraint Settings"]) .alias(&["csettings"]) .params("{infer?, distanceTolerance?, angleTolerance?, autoTypes?: [names], barsVisible?, barTransparency?, dimsVisible?}"), + CommandSpec::new("constraintinfer", "Infer Constraints", run_constraintinfer) + .params("{value?: 0|1} sets CONSTRAINTINFER (no value: reports it)") + .interactive(|_| Ok(Box::new(InferM))), CommandSpec::new("constraints.inspect", "Inspect Constraints", run_inspect).noundo().params("{} → constraints, glyph anchors, parameters, settings, dof"), CommandSpec::new("constraints.dof", "Degrees of Freedom", run_dof) .noundo() @@ -961,6 +964,51 @@ fn run_constraintsettings(s: &mut Session, p: &Value) -> Result { serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string())) } +/// CONSTRAINTINFER typed as a command: report or set the variable. +fn run_constraintinfer(s: &mut Session, p: &Value) -> Result { + if let Some(v) = p.get("value").filter(|v| !v.is_null()) { + let on = + v.as_bool().or_else(|| v.as_f64().filter(|f| *f == 0.0 || *f == 1.0).map(|f| f == 1.0)).or_else(|| match v.as_str().map(str::trim) { + Some("0") => Some(false), + Some("1") => Some(true), + _ => None, + }); + let on = on.ok_or_else(|| bad("constraintinfer", "`value` must be 0 or 1"))?; + if s.doc()?.parametric.settings.infer != on { + s.doc_mut()?.parametric.settings.infer = on; + } + } + let on = s.doc()?.parametric.settings.infer; + Ok(json!({ "value": i32::from(on), "message": format!("CONSTRAINTINFER = {}", i32::from(on)) })) +} + +/// `Enter new value for CONSTRAINTINFER <0>:` +struct InferM; + +impl Interactive for InferM { + fn name(&self) -> &'static str { + "CONSTRAINTINFER" + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = s.doc().map(|d| i32::from(d.parametric.settings.infer)).unwrap_or(0); + Prompt::new("Enter new value for CONSTRAINTINFER", Accept { number: true, ..Accept::TEXT }).default(cur.to_string()) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let text = match i { + Input::Enter | Input::Cancel => return Ok(Step::Done), + Input::Text(t) | Input::Keyword(t) => t, + _ => return Ok(Step::Continue), + }; + match run_constraintinfer(s, &json!({ "value": text.trim() })) { + Ok(_) => Ok(Step::Done), + Err(_) => { + s.echo("Requires an integer between 0 and 1."); + Ok(Step::Continue) + } + } + } +} + fn run_inspect(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; let dof = cs::dof(d, &[]).ok().map(|r| json!({ "dof": r.dof, "status": r.status, "redundant": r.redundant, "message": r.message })); @@ -984,13 +1032,104 @@ fn run_dof(s: &mut Session, p: &Value) -> Result { // ---------------- re-solve after edits ---------------- -/// Called after every command (programmatic or interactive). When the command changed the +/// CONSTRAINTINFER: most objects one command may place for inference to run. +const MAX_INFER_PLACED: usize = 50; +/// CONSTRAINTINFER: most objects scanned for what the placed ones touch. +const MAX_INFER_SCAN: usize = 50_000; + +/// CONSTRAINTINFER: after a command drew or edited objects, constrain what it placed exactly onto +/// existing geometry. Snapped points are exact, so tight tolerances find what the object snaps +/// (running snaps, overrides, tracking) and ortho/polar established, whatever way the point came +/// in. Commands that changed the constraints themselves are left alone. +fn infer_after(s: &mut Session, before: &Drawing) { + let space = s.space(); + let Ok(d) = s.doc() else { return }; + let set = &d.parametric.settings; + if !set.infer || d.constraints != before.constraints { + return; + } + let Some(store) = d.space(&space) else { return }; + let changed = |e: &Arc| before.entity(e.handle).is_none_or(|b| !Arc::ptr_eq(b, e) && **b != **e); + let placed: Vec = store.iter().filter(|e| cs::supported(&e.kind) && changed(e)).map(|e| e.handle).take(MAX_INFER_PLACED + 1).collect(); + if placed.is_empty() || placed.len() > MAX_INFER_PLACED { + return; + } + let types: Vec = cs::INFER_TYPES + .iter() + .map(|k| k.name()) + .filter(|n| set.auto_types.is_empty() || set.auto_types.iter().any(|t| t.eq_ignore_ascii_case(n))) + .map(str::to_string) + .collect(); + if types.is_empty() { + return; + } + // The points the placed objects reach, and how big they are (for the tolerance). + let mut pts: Vec = Vec::new(); + let mut dirs: Vec = Vec::new(); + for h in &placed { + let Some(e) = d.entity(*h) else { continue }; + pts.extend(object_points(d, *h, &e.kind).into_iter().map(|(_, p)| p).filter(|p| p.is_finite()).take(1000)); + if let EntityKind::Line(l) = &e.kind { + let v = (l.b.xy() - l.a.xy()).normalized(); + if v.x.abs() > 1e-9 && v.y.abs() > 1e-9 { + dirs.push(v); + } + } + } + let scale = pts.iter().fold(1.0f64, |m, p| m.max(p.x.abs()).max(p.y.abs())); + let tol = 1e-9 * scale; + // Others: what the placed points touch, and the nearest line exactly parallel to a sloped + // placed line (a parallel snap). + let mut others: Vec = Vec::new(); + let mut parallel: Option<(f64, Handle)> = None; + let near = pts.first().copied().unwrap_or_default(); + for e in store.iter().take(MAX_INFER_SCAN) { + if placed.contains(&e.handle) || !cs::supported(&e.kind) || !d.is_visible(e) { + continue; + } + let b = cadcraft_doc::entity_bounds(d, e, 0).expand(tol); + if pts.iter().any(|p| b.contains(*p)) { + if others.len() < 150 { + others.push(e.handle); + } + } else if let EntityKind::Line(l) = &e.kind + && dirs.iter().any(|v| v.cross((l.b.xy() - l.a.xy()).normalized()).abs() <= 1e-9) + { + let dist = Line::new(l.a.xy(), l.b.xy()).dist(near); + if dist.is_finite() && parallel.is_none_or(|p| dist < p.0) { + parallel = Some((dist, e.handle)); + } + } + } + others.extend(parallel.map(|p| p.1)); + let o = cs::InferOptions { distance_tolerance: tol, angle_tolerance: 1e-7, types }; + let cands = cs::infer_placed(d, &placed, &others, &o); + if cands.is_empty() { + return; + } + let mut nd = d.clone(); + let opts = cs::SolveOptions { keep: others, prefer_move: placed }; + let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count(); + if added > 0 + && let Ok(st) = s.state_mut() + { + st.doc = Arc::new(nd); + } +} + +/// Called after every command (programmatic or interactive). With CONSTRAINTINFER on, first +/// infer constraints for what the command placed ([`infer_after`]). When the command changed the /// drawing and its constraints no longer hold, re-solve them, keeping the geometry the command /// edited where it was put; constraints whose objects are gone are removed. pub fn after_command(s: &mut Session, before: Option<&Arc>, ok: bool) { if !ok { return; } + if let (Some(b), Ok(st)) = (before, s.state()) + && !Arc::ptr_eq(b, &st.doc) + { + infer_after(s, b); + } let (Some(before), Ok(st)) = (before, s.state()) else { return }; if st.doc.constraints.is_empty() || Arc::ptr_eq(before, &st.doc) { return; diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index c3340ac..049b8b2 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -96,6 +96,7 @@ pub fn get(s: &Session, name: &str) -> Option { "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), // Held in radians (as `$ANGBASE` in DXF); reported in degrees, like POLARANG. "ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()), + "CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)), // Process-wide font substitution (profile settings, not saved in the drawing). "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), "FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()), @@ -251,6 +252,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?; s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a)); } + "CONSTRAINTINFER" => s.doc_mut()?.parametric.settings.infer = as_bool(v).ok_or_else(bad)?, _ => { if let Some(r) = table::set(s, &n, v) { return r; diff --git a/crates/engine/tests/constraint_infer.rs b/crates/engine/tests/constraint_infer.rs new file mode 100644 index 0000000..7bdfbf4 --- /dev/null +++ b/crates/engine/tests/constraint_infer.rs @@ -0,0 +1,128 @@ +//! CONSTRAINTINFER: constraints inferred for what a drawing or editing command places with +//! object snaps, ortho or polar (#546). + +use cadcraft_doc::{ConstraintKind as K, EntityKind, Handle, Sub}; +use cadcraft_engine::Session; +use serde_json::json; + +/// A session with a circle at 20,20 radius 3 (not constrained) and CONSTRAINTINFER on. +fn session() -> Session { + let mut s = Session::new(); + s.script("CIRCLE 20,20 3\n").unwrap(); + s.cmdline("constraintinfer 1").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(s.execute("getvar", &json!({ "name": "CONSTRAINTINFER" })).unwrap()["value"], json!(1)); + s +} + +fn kinds(s: &Session) -> Vec { + s.doc().unwrap().constraints.iter().map(|c| c.kind).collect() +} + +fn count(s: &Session, k: K) -> usize { + kinds(s).iter().filter(|x| **x == k).count() +} + +fn lines(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).map(|e| e.handle).collect() +} + +fn circle(s: &Session) -> Handle { + s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Circle(_))).map(|e| e.handle).unwrap() +} + +#[test] +fn off_by_default_and_settable_by_setvar() { + let mut s = Session::new(); + s.script("LINE 0,0 10,0 10,5\n\n").unwrap(); + assert!(kinds(&s).is_empty()); + assert_eq!(s.execute("getvar", &json!({ "name": "constraintinfer" })).unwrap()["value"], json!(0)); + s.execute("setvar", &json!({ "name": "CONSTRAINTINFER", "value": 1 })).unwrap(); + assert!(s.doc().unwrap().parametric.settings.infer); + assert!(s.execute("setvar", &json!({ "name": "CONSTRAINTINFER", "value": "yes" })).is_err()); + for bad in [json!({ "value": 2 }), json!({ "value": "x" }), json!({ "value": [] }), json!({ "value": f64::MAX })] { + assert!(s.execute("constraintinfer", &bad).is_err(), "{bad}"); + } + assert_eq!(s.execute("constraintinfer", &json!({})).unwrap()["value"], json!(1)); + s.execute("constraintinfer", &json!({ "value": false })).unwrap(); + assert!(!s.doc().unwrap().parametric.settings.infer); + // At the command line: Enter keeps the value, a bad one re-prompts. + s.cmdline("constraintinfer").unwrap(); + assert!(s.prompt_text().contains("CONSTRAINTINFER"), "{}", s.prompt_text()); + s.cmdline("7").unwrap(); + assert!(s.running.is_some()); + s.cmdline("1").unwrap(); + assert!(s.running.is_none() && s.doc().unwrap().parametric.settings.infer); +} + +#[test] +fn line_segments_join_and_ortho_directions_are_kept() { + let mut s = session(); + s.script("LINE 0,0 10,0 10,5\n\n").unwrap(); + // The joint, and the horizontal and vertical segments. + assert_eq!(count(&s, K::Coincident), 1, "{:?}", kinds(&s)); + assert_eq!(count(&s, K::Horizontal), 1, "{:?}", kinds(&s)); + assert_eq!(count(&s, K::Vertical), 1, "{:?}", kinds(&s)); + // A sloped line drawn exactly parallel to another (a parallel snap) is kept parallel. + s.script("LINE 0,10 6,13\n\nLINE 0,15 @6,3\n\n").unwrap(); + assert_eq!(count(&s, K::Parallel), 1, "{:?}", kinds(&s)); + // Hand-placed points that touch nothing infer nothing. + let before = kinds(&s).len(); + s.script("LINE 30.3,0.2 37.1,3.3\n\n").unwrap(); + assert_eq!(kinds(&s).len(), before); + // Undo takes the inferred constraints with the line. + s.execute("u", &json!({})).unwrap(); + assert_eq!(lines(&s).len(), 4); + assert_eq!(kinds(&s).len(), before); +} + +#[test] +fn snapped_points_become_coincident_and_tangent() { + let mut s = session(); + let c = circle(&s); + // Centre snap, then an endpoint snap back to the first line. + s.script("LINE 0,0 9,1\n\nLINE CEN 20.05,19.98 END 8.98,1.01\n\n").unwrap(); + let d = s.doc().unwrap(); + let centre = d.constraints.iter().find(|k| k.refs.iter().any(|r| r.handle == c && r.sub == Sub::Center)); + assert!(centre.is_some_and(|k| k.kind == K::Coincident), "{:?}", d.constraints); + let ls = lines(&s); + let joined = d.constraints.iter().any(|k| k.kind == K::Coincident && k.refs.iter().all(|r| ls.contains(&r.handle))); + assert!(joined, "{:?}", d.constraints); + // A tangent snap to the circle: on it and tangent to it. + s.script("LINE 40,20 TAN 20.45,22.97\n\n").unwrap(); + let last = *lines(&s).last().unwrap(); + let d = s.doc().unwrap(); + let with = |k: K| d.constraints.iter().any(|x| x.kind == k && x.refs.iter().any(|r| r.handle == last) && x.refs.iter().any(|r| r.handle == c)); + assert!(with(K::Tangent), "{:?}", d.constraints); + // A perpendicular snap onto the first line, ending on it. + s.script("LINE 3,8 PER 3.84,0.43\n\n").unwrap(); + let last = *lines(&s).last().unwrap(); + let first = *lines(&s).first().unwrap(); + let d = s.doc().unwrap(); + let pair = + |k: K| d.constraints.iter().any(|x| x.kind == k && x.refs.iter().any(|r| r.handle == last) && x.refs.iter().any(|r| r.handle == first)); + assert!(pair(K::Perpendicular) && pair(K::Coincident), "{:?}", d.constraints); + assert_eq!(s.execute("constraints.inspect", &json!({})).unwrap()["satisfied"], json!(true)); +} + +#[test] +fn editing_with_snaps_infers_and_constraint_commands_do_not() { + let mut s = session(); + s.script("LINE 0,0 10,0\n\nLINE 30,7 34,9\n\n").unwrap(); + let ls = lines(&s); + let n0 = kinds(&s).len(); + // MOVE the second line's start exactly onto the first line's end. + s.execute("move", &json!({ "handles": [ls[1].hex()], "from": [30, 7], "to": [10, 0] })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.constraints.iter().skip(n0).any(|k| k.kind == K::Coincident), "{:?}", d.constraints); + // A constraint command adds only its own constraint. + let n = kinds(&s).len(); + s.execute("gcequal", &json!({ "h1": ls[0].hex(), "h2": ls[1].hex() })).unwrap(); + assert_eq!(kinds(&s).len(), n + 1); + // Many objects at once (an array) are not inferred. + let n = kinds(&s).len(); + let before = s.doc().unwrap().model.len(); + s.execute("arrayrect", &json!({ "handles": [ls[0].hex()], "rows": 1, "cols": 60, "rowSpacing": 1, "colSpacing": 0.5 })).unwrap(); + assert!(s.doc().unwrap().model.len() > before + 50); + assert_eq!(kinds(&s).len(), n); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d8a3523..21e356a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -195,6 +195,7 @@ Defined width Задана ширина Degree Градус Degrees of Freedom Ступені вільності + Degrees of freedom: {n} Ступені вільності: {n} Delete Видалити Delete Column Видалити стовпець Delete Constraints Видалити обмеження @@ -394,6 +395,7 @@ Increment angle Крок кута Index Color Індексований колір Index color: {index} Індексований колір: {index} + Infer Constraints Виведення обмежень Inquiry Вимірювання Insert Вставлення Insert Block Вставити блок @@ -571,6 +573,7 @@ Ortho Mode (F8) Режим Орто (F8) Osnap Settings... Параметри об'єктної прив'язки... Out Віддалити + Over-constrained Надмірно обмежено Override Перевизначити PAPER АРКУШ Percent Відсоток diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index f1d59fe..504201f 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -1,6 +1,7 @@ //! Parametric display: geometric constraint bars and dynamic dimensional constraints drawn over //! the canvas (from the engine's `constraints.inspect`), inline editing of a dimensional -//! constraint's expression, and the Parameters Manager. +//! constraint's expression, the degrees of freedom left (`constraints.dof`: a status strip on +//! each constraint bar and a summary on the canvas), and the Parameters Manager. //! //! Every edit runs an engine command (`parameters {name, expr}` / `{delete}`); this module only //! draws and collects input. @@ -30,11 +31,41 @@ pub struct Glyph { pub satisfied: bool, } +/// Degrees of freedom of one constrained object (`constraints.dof`). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct ObjectDof { + pub handle: String, + pub dof: Option, + /// `under`, `fully`, `over` or `unknown`. + pub status: String, +} + +/// The drawing's degrees of freedom as `constraints.dof` reports them. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct Dof { + pub dof: Option, + pub status: String, + pub objects: Vec, +} + +/// What a DOF status reads as, and its colour (`None`: nothing to say). +fn dof_text(status: &str, dof: Option) -> Option<(String, Color32)> { + match (status, dof) { + ("fully", _) => Some((crate::tf!("Degrees of freedom: {n}", n = 0), Color32::from_rgb(0x4c, 0xaf, 0x50))), + ("over", _) => Some((crate::tl!("Over-constrained").to_string(), Color32::from_rgb(0xff, 0x8a, 0x6a))), + ("under", Some(n)) => Some((crate::tf!("Degrees of freedom: {n}", n = n), Color32::from_rgb(0x9f, 0xb6, 0xd6))), + _ => None, + } +} + /// Per-drawing-revision cache of the constraint glyphs (inspect is not free on big drawings). #[derive(Default)] pub struct Cache { key: Option<(u64, u64, usize)>, glyphs: Vec, + dof: Dof, /// The dimensional constraint whose expression is being edited: (name, buffer, screen pos). editing: Option<(String, String, Pos2)>, } @@ -51,6 +82,7 @@ fn refresh(app: &mut CadApp) { } app.canvas.param.key = key; app.canvas.param.glyphs.clear(); + app.canvas.param.dof = Dof::default(); if app.session.doc().is_ok_and(|d| d.constraints.is_empty()) { return; } @@ -59,6 +91,10 @@ fn refresh(app: &mut CadApp) { { app.canvas.param.glyphs = serde_json::from_value(g.clone()).unwrap_or_default(); } + // Every constrained object (an empty list: not the selection). + if let Ok(v) = app.session.execute("constraints.dof", &json!({ "handles": [] })) { + app.canvas.param.dof = serde_json::from_value(v).unwrap_or_default(); + } } // ---------------- geometry of constraint references ---------------- @@ -411,7 +447,10 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter continue; } let n = kinds.len().min(16) as f32; - let mut r = Rect::from_min_size(sp + vec2(8.0, 8.0), vec2(n * cell + 4.0, cell + 4.0)); + let od = app.canvas.param.dof.objects.iter().find(|o| Handle::parse_hex(&o.handle) == Some(*h)); + let status = od.and_then(|o| dof_text(&o.status, o.dof)); + let strip = if status.is_some() { 6.0 } else { 0.0 }; + let mut r = Rect::from_min_size(sp + vec2(8.0, 8.0), vec2(n * cell + 4.0 + strip, cell + 4.0)); for _ in 0..12 { if placed.iter().any(|p| p.intersects(r)) { r = r.translate(vec2(0.0, cell + 6.0)); @@ -432,6 +471,22 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter paint_glyph(painter, cr.shrink(2.0), kind, Color32::from_rgb(0x26, 0x2b, 0x33)); resp.on_hover_text(crate::tf!("{kind} (constraint {id})", kind = crate::i18n::t(kind), id = id)); } + // The object's constraint state: a coloured strip at the end of its bar. + if let Some((text, col)) = status { + let sr = Rect::from_min_max(pos2(r.right() - strip - 1.0, r.top() + 3.0), pos2(r.right() - 2.0, r.bottom() - 3.0)); + painter.rect_filled(sr, 2.0, col.gamma_multiply(alpha.max(0.5))); + ui.interact(sr.expand(2.0), ui.id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); + } + } + // The drawing's constraint state. + if let Some((text, col)) = dof_text(&app.canvas.param.dof.status, app.canvas.param.dof.dof) + && (set.bars_visible || !set.bar_exceptions.is_empty()) + { + let galley = painter.layout_no_wrap(text, crate::theme::small(), col); + let at = pos2(xf.rect.left() + 96.0, xf.rect.bottom() - 10.0 - galley.size().y); + let r = Rect::from_min_size(at, galley.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, 2.0, t.canvas.gamma_multiply(0.85)); + painter.galley(at + vec2(4.0, 2.0), galley, col); } let _ = act_take(app, act, ui); } diff --git a/docs/gaps.md b/docs/gaps.md index 3c15f72..117ac9e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -51,7 +51,6 @@ do them first. | F22 | Inquiry: MASSPROP approximate; COUNT counts model-space inserts only; no QuickCalc palette (CAL is an evaluator); no DWG Compare | `utility.rs:203,310-352`, `inquiry.rs:305` | Low-medium | 15–25 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | | F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | -| F25 | Constraint solver: no DOF report, Smooth not G2, no ellipse/spline/block geometry, no inference while drawing; DCANGULAR can succeed unsatisfied (#73, PR #190) | [geometry-parity](geometry-parity.md#constraint-solver--50) | Low-medium | 20–30 | ## UI/UX diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index b5dbb88..c29d552 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -121,8 +121,8 @@ lines are scanline families clipped against the tessellated loops (`render/src/h | Geometry | lines, arcs, circles, polylines, ellipses, splines, blocks | line, circle, arc, polyline, point; ellipse and elliptical arc (centre, axis ends and arc ends as points; tangent, coincident, concentric, horizontal/vertical on the major axis); spline (fit or control points as points; tangent and smooth at its ends; open clamped or fit splines); block reference (insertion point, and the block's own points riding rigidly with position and rotation) (`model.rs`) | | Algorithm | proprietary | damped Gauss–Newton / Levenberg–Marquardt, numeric Jacobian, per connected component; MAX_ITER 200, MAX_ROWS 800; curve–curve tangency with an ellipse through auxiliary contact parameters | | Over-constraint | refused with a message | redundancy by Jacobian rank (Householder QR with column pivoting, rows and columns balanced), including Fix; the message names the constraints the new one duplicates; conflicts by removal probing (up to 48 probes) | -| Degrees of freedom, fully constrained state | shown (constraint bars, status) | `constraints.dof` and `constraints.inspect`: free parameters minus Jacobian rank per component, per-object DOF from the null space, under/fully/over-constrained status (`dof.rs`); not yet shown in the UI | -| Inference while drawing (CONSTRAINTINFER) | yes | no | +| Degrees of freedom, fully constrained state | shown (constraint bars, status) | `constraints.dof` and `constraints.inspect`: free parameters minus Jacobian rank per component, per-object DOF from the null space, under/fully/over-constrained status (`dof.rs`); a status strip on each constraint bar and the drawing's state on the canvas | +| Inference while drawing (CONSTRAINTINFER) | yes | CONSTRAINTINFER (variable and command): after a drawing or editing command, what it placed exactly onto existing geometry (snaps, ortho, polar) gets Coincident (end, mid, centre, on object), Tangent, Perpendicular, Parallel and Horizontal/Vertical, filtered by the AutoConstrain types (`auto.rs` `infer_placed`) | ## Tests