mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
58eefa8432
commit
0135e793f7
9 files changed
+443
-8
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user