Constraints: CONSTRAINTINFER while drawing and editing, DOF status on the canvas (fixes #546) (#555)

Squash of PR #555 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:48:06 -04:00
1 parent 58eefa8432
commit 0135e793f7
9 files changed
+443 -8

No files matched your search

+110 -1
View File
@@ -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<Constrain
out
}
/// Constraint types CONSTRAINTINFER applies.
pub const INFER_TYPES: [ConstraintKind; 6] = [
ConstraintKind::Coincident,
ConstraintKind::Tangent,
ConstraintKind::Perpendicular,
ConstraintKind::Parallel,
ConstraintKind::Horizontal,
ConstraintKind::Vertical,
];
/// CONSTRAINTINFER: the constraints that the exact placement of `placed` (objects a command just
/// drew or edited) implies against each other and `others`: end points snapped onto end points,
/// midpoints, centres or onto objects (Coincident), tangent and perpendicular snaps, parallel
/// lines, and horizontal/vertical lines (ortho, polar). Snapped geometry is exact, so the
/// tolerances in `o` are expected to be tight. Only [`INFER_TYPES`] (and `o.types`, when set)
/// are returned, each involving a placed object.
pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &InferOptions) -> Vec<Constraint> {
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.0 };
let mut all: Vec<Handle> = 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<Constraint> = 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<u32> {
+1 -1
View File
@@ -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};
+140 -1
View File
@@ -98,6 +98,9 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
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<Value> {
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<Step> {
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<Value> {
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<Value> {
// ---------------- 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<cadcraft_doc::Entity>| before.entity(e.handle).is_none_or(|b| !Arc::ptr_eq(b, e) && **b != **e);
let placed: Vec<Handle> = 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<String> = 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<Vec2> = Vec::new();
let mut dirs: Vec<Vec2> = 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<Handle> = 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<Drawing>>, 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;
+2
View File
@@ -96,6 +96,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"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;
+128
View File
@@ -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<K> {
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<Handle> {
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);
}
+3
View File
@@ -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 Відсоток
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
195
196
197
198
199
200
201
395
396
397
398
399
400
401
573
574
575
576
577
578
579
+57 -2
View File
@@ -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<usize>,
/// `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<usize>,
pub status: String,
pub objects: Vec<ObjectDof>,
}
/// What a DOF status reads as, and its colour (`None`: nothing to say).
fn dof_text(status: &str, dof: Option<usize>) -> 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<Glyph>,
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);
}
-1
View File
@@ -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
+2 -2
View File
@@ -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