From f9aa1b279a4c7ecd154e5fe42d465fc348a13c94 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 01:27:38 -0400 Subject: [PATCH] M4 annotation depth + M9 parametric constraints Annotation (M4): - TrueType text from system fonts (filled/outlined), shared placement for all text - MTEXT formatting codes: fonts, heights, colours, under/overline, stacks, wrapping - Full DIMSTYLE variables, per-dimension overrides, all arrowheads and ticks, unit formats, tolerances, limits, alternate units, colours by role - Associative dimensions that follow geometry; DIMREASSOCIATE/DIMDISASSOCIATE - STYLE/DIMSTYLE management commands, DIMTEDIT, DIMSPACE, TABLE create/edit/merge Parametric (M9): - New cadcraft-constraints crate: weighted damped Gauss-Newton solver, conflict and redundancy detection, expressions, AutoConstrain inference - All GC*/DC* commands, GEOMCONSTRAINT/DIMCONSTRAINT, PARAMETERS, DELCONSTRAINT, constraint bar / display settings; re-solve after every edit Also: MODEL/PAPER status bar toggle, cargo xtask alias, roadmap update (233/491 menu items, 290 commands). Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 2 + AGENTS.md | 2 +- CLAUDE.md | 2 +- Cargo.lock | 12 + Cargo.toml | 1 + ROADMAP.md | 28 +- crates/constraints/Cargo.toml | 15 + crates/constraints/src/auto.rs | 204 +++++ crates/constraints/src/expr.rs | 324 +++++++ crates/constraints/src/lib.rs | 592 +++++++++++++ crates/constraints/src/lm.rs | 271 ++++++ crates/constraints/src/model.rs | 260 ++++++ crates/constraints/src/residual.rs | 392 ++++++++ crates/constraints/src/tests.rs | 435 +++++++++ crates/doc/src/constraint.rs | 227 +++++ crates/doc/src/entity.rs | 49 + crates/doc/src/lib.rs | 8 + crates/doc/src/tables.rs | 165 ++++ crates/engine/Cargo.toml | 1 + crates/engine/src/assoc.rs | 294 ++++++ crates/engine/src/cmd/annotate.rs | 449 +++++++++- crates/engine/src/cmd/annotate_tests.rs | 326 +++++++ crates/engine/src/cmd/constraints.rs | 981 +++++++++++++++++++++ crates/engine/src/cmd/constraints_tests.rs | 192 ++++ crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/props.rs | 415 ++++++++- crates/engine/src/cmd/table.rs | 456 ++++++++++ crates/engine/src/lib.rs | 5 + crates/engine/src/sample.rs | 6 + crates/fonts/src/lib.rs | 258 +++++- crates/fonts/src/mtext.rs | 605 ++++++++++++- crates/fonts/src/ttf.rs | 148 +++- crates/io/src/dxf_read.rs | 2 + crates/io/src/tests.rs | 2 + crates/render/Cargo.toml | 3 + crates/render/src/dim.rs | 930 ++++++++++++++++--- crates/render/src/lib.rs | 325 ++++--- crates/render/src/tests.rs | 185 ++++ crates/render/src/units.rs | 294 ++++++ crates/ui-egui/src/chrome.rs | 17 +- docs/parity.md | 39 +- xtask/src/layers.rs | 1 + 42 files changed, 8497 insertions(+), 430 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 crates/constraints/Cargo.toml create mode 100644 crates/constraints/src/auto.rs create mode 100644 crates/constraints/src/expr.rs create mode 100644 crates/constraints/src/lib.rs create mode 100644 crates/constraints/src/lm.rs create mode 100644 crates/constraints/src/model.rs create mode 100644 crates/constraints/src/residual.rs create mode 100644 crates/constraints/src/tests.rs create mode 100644 crates/doc/src/constraint.rs create mode 100644 crates/engine/src/assoc.rs create mode 100644 crates/engine/src/cmd/annotate_tests.rs create mode 100644 crates/engine/src/cmd/constraints.rs create mode 100644 crates/engine/src/cmd/constraints_tests.rs create mode 100644 crates/engine/src/cmd/table.rs create mode 100644 crates/render/src/units.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/AGENTS.md b/AGENTS.md index 3fb0979f..f31f8657 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Shared test corpora** live in separate repos (`storytold/-corpus`); never commit large binary fixtures here. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands. - **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. -- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** +- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. diff --git a/CLAUDE.md b/CLAUDE.md index 3fb0979f..f31f8657 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Shared test corpora** live in separate repos (`storytold/-corpus`); never commit large binary fixtures here. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands. - **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. -- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** +- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. diff --git a/Cargo.lock b/Cargo.lock index 305d507c..23a4ff0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,6 +566,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cadcraft-constraints" +version = "0.1.0" +dependencies = [ + "cadcraft-doc", + "cadcraft-geom", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "cadcraft-doc" version = "0.1.0" @@ -596,6 +606,7 @@ name = "cadcraft-engine" version = "0.1.0" dependencies = [ "cadcraft-color", + "cadcraft-constraints", "cadcraft-doc", "cadcraft-fonts", "cadcraft-geom", @@ -656,6 +667,7 @@ dependencies = [ "cadcraft-doc", "cadcraft-fonts", "cadcraft-geom", + "serde_json", "tiny-skia", ] diff --git a/Cargo.toml b/Cargo.toml index f17f185b..ce7bfc8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ cadcraft-dxf = { path = "crates/dxf" } cadcraft-dwg = { path = "crates/dwg" } cadcraft-color = { path = "crates/color" } cadcraft-doc = { path = "crates/doc" } +cadcraft-constraints = { path = "crates/constraints" } cadcraft-fonts = { path = "crates/fonts" } cadcraft-render = { path = "crates/render" } cadcraft-io = { path = "crates/io" } diff --git a/ROADMAP.md b/ROADMAP.md index b5e47a7f..7817d091 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,9 +8,9 @@ agent control over MCP, a scriptable CLI, a web build and a free licence. | Measure | Value | |---|---| -| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **204 / 491 reference menu items (42%)** | -| Registered commands | 227 (104 with interactive prompts) | -| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 20%** | +| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **233 / 491 reference menu items (47%)** | +| Registered commands | 290 (133 with interactive prompts) | +| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 26%** | | Tests | geometry, colour, document, fonts, render, DXF, engine (commands, prompts, snaps, selection, undo, scripts, hostile input), MCP agent tasks, xtask | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm — green | @@ -27,26 +27,26 @@ interactive DIM commands yet). | M1 | Drafting core: every Draw-menu 2D command and option, dynamic input fields, object snap tracking, temporary snap overrides | ~85% (all 2D Draw items exist) | 8 | | M2 | Modify: grips (stretch/move/rotate/scale/mirror, multifunctional), PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curve types, MATCHPROP UI, Quick Properties | ~65% (PEDIT, LENGTHEN, ALIGN, BLEND, grips API, TRIM on splines) | 18 | | M3 | Layers & properties: full Layer Properties Manager (filters, VP overrides, states), linetype manager, lineweight display, transparency, QSELECT dialog, Properties for every object type | ~45% | 20 | -| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~15% | 60 | +| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~45% (TrueType text, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; not yet: DXF round-trip of the new data, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 35 | | M5 | Hatch & blocks: pick-point boundary detection, islands, gradients, BLOCK/WBLOCK/INSERT dialogs, attributes (ATTDEF/ATTEDIT/BATTMAN), block editor, dynamic block parameters, xrefs, groups, Blocks palette | ~20% | 70 | -| M6 | Layouts & plotting: paper space, viewports (rect/polygonal/object, scale, lock, per-VP layers), page setups, PLOT to PDF/PNG/SVG, plot styles (CTB/STB), PUBLISH | ~45% (layouts, viewports, page setup, PLOT/EXPORTPDF) | 30 | +| M6 | Layouts & plotting: paper space, viewports (rect/polygonal/object, scale, lock, per-VP layers), page setups, PLOT to PDF/PNG/SVG, plot styles (CTB/STB), PUBLISH | ~55% (layouts, viewports, MSPACE/PSPACE through viewports, page setup, PLOT/EXPORTPDF) | 25 | | M7 | Files: DWG read/write (done via acadrust), DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~45% | 60 | | M8 | Inquiry & utilities: MEASUREGEOM, MASSPROP, QuickCalc, Find/Replace, spell check, COUNT, DWG Compare, Settings/OPTIONS, CUI-style customisation, alias editor | ~20% | 30 | -| M9 | Parametric: geometric + dimensional constraints, AutoConstrain, Parameters Manager (constraint solver, see plan/adr/0001) | 0% | 50 | +| M9 | Parametric: geometric + dimensional constraints, AutoConstrain, Parameters Manager (constraint solver, see plan/adr/0001) | ~60% (own solver crate, all GC*/DC* commands, AUTOCONSTRAIN, PARAMETERS, conflict detection, re-solve after edits; not yet: constraint glyph/dimension rendering, DWG/DXF persistence, inference while drawing) | 20 | | M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~60% (GPU canvas, R-tree: pick 50 ms → 0.001 ms at 200k entities) | 15 | | M11 | 3D: UCS, orbit, visual styles, solids (box…loft, booleans, fillet edges), meshes, surfaces, sections, rendering | 0% | 200 | | M12 | Automation: an embedded safe AutoLISP-compatible interpreter, action recorder, sheet sets, CLI/MCP parity tests | ~25% | 60 | | M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% | 40 | -| | **Remaining total** | | **≈ 680 hours** | +| | **Remaining total** | | **≈ 620 hours** | -At roughly 680 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to -about 170–220 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity +At roughly 620 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to +about 155–200 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity (M1–M8) is the first ≈ 300 hours. ## Current focus -1. Interactive DIM commands (DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMCONTINUE, DIMBASELINE). -2. HATCH with pick-point boundary detection; BLOCK / INSERT. -3. Grip editing. -4. Release pipeline (signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm, FreeBSD, web). -5. GPU canvas for large drawings. +1. Constraint glyphs and dimensional constraints on the canvas; DXF persistence of constraints, DIMSTYLE overrides and associativity. +2. Quick Properties, QSELECT and the full Layer Properties Manager. +3. Plot preview; polygonal/object viewports. +4. 3D foundations (UCS, orbit, solids via truck/csgrs per plan/adr/0001). +5. First release-candidate build through the release pipeline. diff --git a/crates/constraints/Cargo.toml b/crates/constraints/Cargo.toml new file mode 100644 index 00000000..0c66ef64 --- /dev/null +++ b/crates/constraints/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cadcraft-constraints" +description = "CADCraft parametric solver: geometric and dimensional constraints (own damped Gauss-Newton solver), expressions" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +cadcraft-geom = { workspace = true } +cadcraft-doc = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/constraints/src/auto.rs b/crates/constraints/src/auto.rs new file mode 100644 index 00000000..c6754f2f --- /dev/null +++ b/crates/constraints/src/auto.rs @@ -0,0 +1,204 @@ +//! AUTOCONSTRAIN: infer constraints from geometry that already nearly satisfies them. + +use cadcraft_doc::{Constraint, ConstraintKind, Drawing, EntityKind, GeomRef, Handle, Sub}; +use cadcraft_geom::{Line, Vec2}; + +/// Most objects considered at once. +pub const MAX_OBJECTS: usize = 200; +/// Most candidates returned. +pub const MAX_CANDIDATES: usize = 2000; + +/// Inference options. +#[derive(Clone, Debug)] +pub struct InferOptions { + pub distance_tolerance: f64, + /// Degrees. + pub angle_tolerance: f64, + /// Constraint type names allowed (`Coincident`, `Horizontal`, …); empty = all. + pub types: Vec, +} + +impl Default for InferOptions { + fn default() -> Self { + InferOptions { distance_tolerance: 0.05, angle_tolerance: 1.0, types: Vec::new() } + } +} + +struct Geo { + points: Vec<(GeomRef, Vec2)>, + lines: Vec<(GeomRef, Vec2, Vec2)>, + curves: Vec<(GeomRef, Vec2, f64)>, +} + +fn collect(d: &Drawing, handles: &[Handle]) -> Geo { + let mut g = Geo { points: Vec::new(), lines: Vec::new(), curves: Vec::new() }; + for &h in handles.iter().take(MAX_OBJECTS) { + let Some(e) = d.entity(h) else { continue }; + match &e.kind { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + if a.is_finite() && b.is_finite() { + g.points.push((GeomRef::new(h, Sub::Start), a)); + g.points.push((GeomRef::new(h, Sub::End), b)); + g.lines.push((GeomRef::whole(h), a, b)); + } + } + EntityKind::Arc(a) => { + let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); + if ga.center.is_finite() && a.radius.is_finite() { + g.points.push((GeomRef::new(h, Sub::Start), ga.start_point())); + g.points.push((GeomRef::new(h, Sub::End), ga.end_point())); + g.curves.push((GeomRef::whole(h), ga.center, a.radius)); + } + } + EntityKind::Circle(c) => { + if c.center.is_finite() && c.radius.is_finite() { + g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius)); + } + } + EntityKind::Point(p) => { + if p.p.is_finite() { + g.points.push((GeomRef::whole(h), p.p.xy())); + } + } + EntityKind::LwPolyline(p) => { + let n = p.vertices.len(); + if !(2..=4096).contains(&n) { + continue; + } + if !p.closed + && let (Some(f), Some(l)) = (p.vertices.first(), p.vertices.last()) + { + g.points.push((GeomRef::new(h, Sub::Vertex(0)), f.p)); + g.points.push((GeomRef::new(h, Sub::Vertex((n - 1) as u32)), l.p)); + } + let segs = if p.closed { n } else { n - 1 }; + for i in 0..segs { + let (Some(a), Some(b)) = (p.vertices.get(i), p.vertices.get((i + 1) % n)) else { continue }; + if a.bulge.abs() < 1e-9 && a.p.is_finite() && b.p.is_finite() { + g.lines.push((GeomRef::new(h, Sub::Segment(i as u32)), a.p, b.p)); + } + } + } + _ => {} + } + } + g.points.truncate(1000); + g.lines.truncate(1000); + g.curves.truncate(1000); + g +} + +fn ang_between(d1: Vec2, d2: Vec2) -> f64 { + // Acute angle between two directions, radians in [0, π/2]. + let a = d1.cross(d2).atan2(d1.dot(d2)).abs(); + if a > std::f64::consts::FRAC_PI_2 { std::f64::consts::PI - a } else { a } +} + +/// Candidate constraints, most important first (coincident, concentric, tangent, horizontal, +/// vertical, collinear, perpendicular, parallel, equal). Apply them one by one with +/// [`crate::add`], skipping the ones it refuses as redundant. +pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec { + let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 }; + let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() }; + let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name())); + let g = collect(d, handles); + let mut out: Vec = Vec::new(); + let mut push = |kind: ConstraintKind, refs: Vec| { + if out.len() < MAX_CANDIDATES && allow(kind) { + out.push(Constraint { id: 0, kind, refs, name: String::new(), expr: String::new() }); + } + }; + let pairs = |n: usize| (0..n).flat_map(move |i| (i + 1..n).map(move |j| (i, j))); + // Coincident end points of different objects. + for (i, j) in pairs(g.points.len()) { + let (Some((ra, a)), Some((rb, b))) = (g.points.get(i), g.points.get(j)) else { continue }; + if ra.handle != rb.handle && a.dist(*b) <= tol { + push(ConstraintKind::Coincident, vec![*ra, *rb]); + } + } + // Concentric and tangent circles. + for (i, j) in pairs(g.curves.len()) { + let (Some((ra, ca, r1)), Some((rb, cb, r2))) = (g.curves.get(i), g.curves.get(j)) else { continue }; + let dist = ca.dist(*cb); + if dist <= tol { + push(ConstraintKind::Concentric, vec![*ra, *rb]); + } else if (dist - (r1 + r2)).abs() <= tol || (dist - (r1 - r2).abs()).abs() <= tol { + push(ConstraintKind::Tangent, vec![*ra, *rb]); + } + } + for (rl, a, b) in &g.lines { + for (rc, c, r) in &g.curves { + if rl.handle == rc.handle { + continue; + } + let ln = Line::new(*a, *b); + let t = ln.param_of(*c); + if (ln.dist(*c) - r).abs() <= tol && (-1e-9..=1.0 + 1e-9).contains(&t) && Line::new(*a, *b).len() > tol { + push(ConstraintKind::Tangent, vec![*rl, *rc]); + } + } + } + // Horizontal / vertical. + for (r, a, b) in &g.lines { + let dvec = *b - *a; + if dvec.len() <= tol { + continue; + } + if ang_between(dvec, Vec2::X) <= atol { + push(ConstraintKind::Horizontal, vec![*r]); + } else if ang_between(dvec, Vec2::Y) <= atol { + push(ConstraintKind::Vertical, vec![*r]); + } + } + // Line pairs. + let connected = |a1: Vec2, b1: Vec2, a2: Vec2, b2: Vec2| [a2, b2].iter().any(|p| p.dist(a1) <= tol || p.dist(b1) <= tol); + let mut parallel = Vec::new(); + let mut equal = Vec::new(); + for (i, j) in pairs(g.lines.len()) { + let (Some((r1, a1, b1)), Some((r2, a2, b2))) = (g.lines.get(i), g.lines.get(j)) else { continue }; + let (d1, d2) = (*b1 - *a1, *b2 - *a2); + if d1.len() <= tol || d2.len() <= tol { + continue; + } + let ang = ang_between(d1, d2); + let l1 = Line::new(*a1, *b1); + if ang <= atol { + if l1.dist(*a2) <= tol && l1.dist(*b2) <= tol { + push(ConstraintKind::Collinear, vec![*r1, *r2]); + } else { + parallel.push((*r1, *r2)); + } + } else if (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol && connected(*a1, *b1, *a2, *b2) { + push(ConstraintKind::Perpendicular, vec![*r1, *r2]); + } + if (d1.len() - d2.len()).abs() <= tol { + equal.push((*r1, *r2)); + } + } + for (a, b) in parallel { + push(ConstraintKind::Parallel, vec![a, b]); + } + for (a, b) in equal { + push(ConstraintKind::Equal, vec![a, b]); + } + for (i, j) in pairs(g.curves.len()) { + let (Some((ra, _, r1)), Some((rb, _, r2))) = (g.curves.get(i), g.curves.get(j)) else { continue }; + if (r1 - r2).abs() <= tol { + push(ConstraintKind::Equal, vec![*ra, *rb]); + } + } + out +} + +/// 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 { + let mut added = Vec::new(); + for c in infer(d, handles, o) { + if let Ok(id) = crate::add(d, c, &crate::SolveOptions::default()) { + added.push(id); + } + } + added +} diff --git a/crates/constraints/src/expr.rs b/crates/constraints/src/expr.rs new file mode 100644 index 00000000..cf83ad07 --- /dev/null +++ b/crates/constraints/src/expr.rs @@ -0,0 +1,324 @@ +//! A tiny expression language for dimensional constraints and user parameters: numbers, +//! parameter names, `+ - * /`, unary minus, parentheses and the constant `pi`. + +use std::collections::BTreeMap; + +/// Longest expression accepted (characters). +pub const MAX_LEN: usize = 1024; +/// Deepest nesting (parentheses, unary minus) and parameter reference chain. +pub const MAX_DEPTH: usize = 64; + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +pub enum ExprError { + #[error("syntax error: {0}")] + Syntax(String), + #[error("unknown parameter `{0}`")] + Unknown(String), + #[error("parameter `{0}` refers to itself")] + Cycle(String), + #[error("division by zero")] + DivZero, + #[error("expression too long or too deeply nested")] + TooDeep, + #[error("result is not a finite number")] + NotFinite, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Expr { + Num(f64), + Var(String), + Neg(Box), + Bin(char, Box, Box), +} + +/// True for a valid parameter name: a letter or `_`, then letters, digits or `_`. +pub fn valid_name(s: &str) -> bool { + let mut c = s.chars(); + matches!(c.next(), Some(ch) if ch.is_ascii_alphabetic() || ch == '_') + && c.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + && s.len() <= 64 + && !s.eq_ignore_ascii_case("pi") +} + +struct Parser<'a> { + s: &'a [u8], + i: usize, + depth: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) { + self.i += 1; + } + } + fn peek(&mut self) -> Option { + self.ws(); + self.s.get(self.i).copied() + } + fn enter(&mut self) -> Result<(), ExprError> { + self.depth += 1; + if self.depth > MAX_DEPTH { Err(ExprError::TooDeep) } else { Ok(()) } + } + fn sum(&mut self) -> Result { + self.enter()?; + let mut e = self.product()?; + while let Some(c @ (b'+' | b'-')) = self.peek() { + self.i += 1; + let r = self.product()?; + e = Expr::Bin(c as char, Box::new(e), Box::new(r)); + } + self.depth -= 1; + Ok(e) + } + fn product(&mut self) -> Result { + let mut e = self.unary()?; + while let Some(c @ (b'*' | b'/')) = self.peek() { + self.i += 1; + let r = self.unary()?; + e = Expr::Bin(c as char, Box::new(e), Box::new(r)); + } + Ok(e) + } + fn unary(&mut self) -> Result { + match self.peek() { + Some(b'-') => { + self.i += 1; + self.enter()?; + let e = self.unary()?; + self.depth -= 1; + Ok(Expr::Neg(Box::new(e))) + } + Some(b'+') => { + self.i += 1; + self.enter()?; + let e = self.unary(); + self.depth -= 1; + e + } + _ => self.atom(), + } + } + fn atom(&mut self) -> Result { + match self.peek() { + Some(b'(') => { + self.i += 1; + let e = self.sum()?; + if self.peek() != Some(b')') { + return Err(ExprError::Syntax("missing `)`".into())); + } + self.i += 1; + Ok(e) + } + Some(c) if c.is_ascii_digit() || c == b'.' => { + let start = self.i; + while self.s.get(self.i).is_some_and(|c| c.is_ascii_digit() || *c == b'.') { + self.i += 1; + } + // Exponent: 1e3, 2.5E-2. + if self.s.get(self.i).is_some_and(|c| *c == b'e' || *c == b'E') { + let save = self.i; + self.i += 1; + if self.s.get(self.i).is_some_and(|c| *c == b'+' || *c == b'-') { + self.i += 1; + } + if self.s.get(self.i).is_some_and(u8::is_ascii_digit) { + while self.s.get(self.i).is_some_and(u8::is_ascii_digit) { + self.i += 1; + } + } else { + self.i = save; + } + } + let t = std::str::from_utf8(self.s.get(start..self.i).unwrap_or_default()).unwrap_or_default(); + let v: f64 = t.parse().map_err(|_| ExprError::Syntax(format!("bad number `{t}`")))?; + Ok(Expr::Num(v)) + } + Some(c) if c.is_ascii_alphabetic() || c == b'_' => { + let start = self.i; + while self.s.get(self.i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') { + self.i += 1; + } + let t = std::str::from_utf8(self.s.get(start..self.i).unwrap_or_default()).unwrap_or_default(); + if t.eq_ignore_ascii_case("pi") { + return Ok(Expr::Num(std::f64::consts::PI)); + } + Ok(Expr::Var(t.to_string())) + } + Some(c) => Err(ExprError::Syntax(format!("unexpected `{}`", c as char))), + None => Err(ExprError::Syntax("unexpected end".into())), + } + } +} + +/// Parse an expression. +pub fn parse(text: &str) -> Result { + if text.len() > MAX_LEN { + return Err(ExprError::TooDeep); + } + if text.trim().is_empty() { + return Err(ExprError::Syntax("empty expression".into())); + } + let mut p = Parser { s: text.as_bytes(), i: 0, depth: 0 }; + let e = p.sum()?; + if p.peek().is_some() { + return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim()))); + } + Ok(e) +} + +/// Names referenced by an expression. +pub fn names(e: &Expr, out: &mut Vec) { + match e { + Expr::Num(_) => {} + Expr::Var(n) => { + if !out.contains(n) { + out.push(n.clone()); + } + } + Expr::Neg(a) => names(a, out), + Expr::Bin(_, a, b) => { + names(a, out); + names(b, out); + } + } +} + +/// Evaluate with a variable lookup. +pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result) -> Result { + let v = match e { + Expr::Num(v) => *v, + Expr::Var(n) => lookup(n)?, + Expr::Neg(a) => -eval_with(a, lookup)?, + Expr::Bin(op, a, b) => { + let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?); + match op { + '+' => x + y, + '-' => x - y, + '*' => x * y, + _ => { + if y == 0.0 { + return Err(ExprError::DivZero); + } + x / y + } + } + } + }; + if v.is_finite() { Ok(v) } else { Err(ExprError::NotFinite) } +} + +/// Evaluate an expression that uses no names. +pub fn eval_const(text: &str) -> Result { + eval_with(&parse(text)?, &mut |n| Err(ExprError::Unknown(n.to_string()))) +} + +/// A set of named expressions (user parameters and dimensional constraint names), evaluated +/// lazily with cycle detection. +#[derive(Clone, Debug, Default)] +pub struct Env { + exprs: BTreeMap, + cache: BTreeMap, +} + +impl Env { + pub fn new() -> Self { + Env::default() + } + pub fn insert(&mut self, name: &str, expr: &str) { + self.exprs.insert(name.to_string(), expr.to_string()); + self.cache.clear(); + } + pub fn contains(&self, name: &str) -> bool { + self.exprs.contains_key(name) + } + /// Value of a named parameter. + pub fn value(&mut self, name: &str) -> Result { + let mut stack = Vec::new(); + self.value_inner(name, &mut stack) + } + fn value_inner(&mut self, name: &str, stack: &mut Vec) -> Result { + if let Some(v) = self.cache.get(name) { + return Ok(*v); + } + if stack.iter().any(|s| s == name) { + return Err(ExprError::Cycle(name.to_string())); + } + if stack.len() >= MAX_DEPTH { + return Err(ExprError::TooDeep); + } + let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?; + let e = parse(&text)?; + stack.push(name.to_string()); + let r = eval_with(&e, &mut |n| self.value_inner(n, stack)); + stack.pop(); + let v = r?; + self.cache.insert(name.to_string(), v); + Ok(v) + } + /// Evaluate a free expression against this environment. + pub fn eval(&mut self, text: &str) -> Result { + let e = parse(text)?; + let mut stack = Vec::new(); + eval_with(&e, &mut |n| self.value_inner(n, &mut stack)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arithmetic() { + assert_eq!(eval_const("1+2*3").unwrap(), 7.0); + assert_eq!(eval_const("(1+2)*3").unwrap(), 9.0); + assert_eq!(eval_const("-2*-3").unwrap(), 6.0); + assert_eq!(eval_const("10/4").unwrap(), 2.5); + assert_eq!(eval_const("1e2+.5").unwrap(), 100.5); + assert_eq!(eval_const("8-2-1").unwrap(), 5.0); + assert!((eval_const("pi").unwrap() - std::f64::consts::PI).abs() < 1e-15); + } + + #[test] + fn errors() { + assert_eq!(eval_const("1/0"), Err(ExprError::DivZero)); + assert!(matches!(eval_const("1+"), Err(ExprError::Syntax(_)))); + assert!(matches!(eval_const("(1"), Err(ExprError::Syntax(_)))); + assert!(matches!(eval_const("1 2"), Err(ExprError::Syntax(_)))); + assert!(matches!(eval_const("x"), Err(ExprError::Unknown(_)))); + assert!(matches!(eval_const(""), Err(ExprError::Syntax(_)))); + assert!(matches!(eval_const("1e308*1e308"), Err(ExprError::NotFinite))); + assert!(matches!(eval_const(&"(".repeat(500)), Err(ExprError::TooDeep))); + assert!(matches!(eval_const(&"-".repeat(500)), Err(ExprError::TooDeep))); + assert!(matches!(eval_const(&"1+".repeat(600)), Err(ExprError::TooDeep))); + assert!(eval_const("1.2.3").is_err()); + assert!(eval_const("é").is_err()); + } + + #[test] + fn env_and_cycles() { + let mut e = Env::new(); + e.insert("w", "10"); + e.insert("h", "w/2"); + e.insert("a", "b+1"); + e.insert("b", "a+1"); + assert_eq!(e.value("h").unwrap(), 5.0); + assert_eq!(e.eval("w*h").unwrap(), 50.0); + assert!(matches!(e.value("a"), Err(ExprError::Cycle(_)))); + assert!(matches!(e.value("zz"), Err(ExprError::Unknown(_)))); + let mut chain = Env::new(); + for i in 0..200 { + chain.insert(&format!("p{i}"), &format!("p{}+1", i + 1)); + } + assert!(chain.value("p0").is_err()); + } + + #[test] + fn names_valid() { + assert!(valid_name("d1") && valid_name("_w") && !valid_name("1d") && !valid_name("a-b") && !valid_name("") && !valid_name("pi")); + let mut v = Vec::new(); + names(&parse("a+b*a").unwrap(), &mut v); + assert_eq!(v, vec!["a".to_string(), "b".to_string()]); + } +} diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs new file mode 100644 index 00000000..bd41c516 --- /dev/null +++ b/crates/constraints/src/lib.rs @@ -0,0 +1,592 @@ +//! CADCraft parametric solver. +//! +//! Maps constrained lines, arcs, circles, points and polyline vertices to a parameter vector +//! ([`model`]), turns each [`Constraint`] into residual equations ([`residual`]), and drives the +//! residuals to zero with a weighted minimum-norm damped Gauss–Newton solver ([`lm`]), so +//! geometry moves as little as possible and geometry the caller wants kept (just edited, or picked +//! first) moves least. Fix locks parameters. Constraints are solved per connected component. +//! +//! Inconsistent systems are not "solved" by moving things wildly: the solve fails, nothing is +//! written back, and the error names the conflicting constraints (each one whose removal makes +//! the rest solvable). New constraints that add no independent equation are reported as +//! redundant (over-constraining), as AutoCAD does. +//! +//! Own implementation (clean-room): the KittyCAD `ezpz` crate (MIT) was evaluated; it pulls in +//! the `faer` linear-algebra stack and models KCL's geometry, so a small solver tailored to the +//! drawing database (minimal movement, conflict reporting) was written instead. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod auto; +pub mod expr; +mod lm; +mod model; +mod residual; + +use std::collections::{BTreeMap, HashMap}; + +use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle}; +use cadcraft_geom::Vec2; +use serde::Serialize; + +pub use auto::{InferOptions, autoconstrain, infer}; +pub use expr::{Env, ExprError}; +pub use model::supported; + +use model::Model; +use residual::{Built, Form}; + +/// Most constraints one drawing may hold. +pub const MAX_CONSTRAINTS: usize = 5000; +/// Constraints tried one by one when locating a conflict. +const MAX_CONFLICT_PROBES: usize = 48; +/// Solver iterations. +const MAX_ITER: usize = 200; + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +pub enum SolveError { + #[error("parameter `{name}`: {err}")] + Expr { name: String, err: ExprError }, + #[error("constraint {id}: {msg}")] + Invalid { id: u32, msg: String }, + #[error("constraints conflict: {}", ids_text(ids))] + Conflict { ids: Vec }, + #[error("constraint would over-constrain the geometry (redundant with {})", ids_text(ids))] + Redundant { ids: Vec }, + #[error("constraint system too large to solve")] + TooLarge, + #[error("{0}")] + Other(String), +} + +fn ids_text(ids: &[u32]) -> String { + if ids.is_empty() { + return "existing constraints".into(); + } + ids.iter().map(|i| format!("#{i}")).collect::>().join(", ") +} + +/// Which geometry should prefer to stay or move. +#[derive(Clone, Debug, Default)] +pub struct SolveOptions { + /// Entities that should move as little as possible (just edited, or picked first). + pub keep: Vec, + /// Entities that should take up the change. + pub prefer_move: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct SolveReport { + /// Entities whose geometry changed. + pub moved: Vec, + /// Constraints that could not be interpreted (and were ignored), with the reason. + pub skipped: Vec<(u32, String)>, +} + +// ---------------- parameters ---------------- + +/// The expression environment: user parameters and dimensional constraint names. +pub fn env(d: &Drawing) -> Env { + let mut e = Env::new(); + for p in &d.parametric.parameters { + e.insert(&p.name, &p.expr); + } + for c in &d.constraints { + if c.kind.is_dimensional() && !c.name.is_empty() { + e.insert(&c.name, &c.expr); + } + } + e +} + +/// The value of a dimensional constraint (degrees for angular). +pub fn constraint_value(env: &mut Env, c: &Constraint) -> Result { + let label = if c.name.is_empty() { format!("#{}", c.id) } else { c.name.clone() }; + let r = if c.name.is_empty() || !env.contains(&c.name) { env.eval(&c.expr) } else { env.value(&c.name) }; + r.map_err(|err| SolveError::Expr { name: label, err }) +} + +/// A row of the Parameters Manager. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParamRow { + pub name: String, + pub expr: String, + pub value: Option, + pub error: Option, + /// `dimensional` or `user`. + pub kind: &'static str, + pub constraint: Option, + pub description: String, +} + +pub fn parameter_table(d: &Drawing) -> Vec { + let mut e = env(d); + let mut rows = Vec::new(); + for c in d.constraints.iter().filter(|c| c.kind.is_dimensional()) { + let r = constraint_value(&mut e, c); + rows.push(ParamRow { + name: c.name.clone(), + expr: c.expr.clone(), + value: r.as_ref().ok().copied(), + error: r.err().map(|e| e.to_string()), + kind: "dimensional", + constraint: Some(c.id), + description: c.kind.name().to_string(), + }); + } + for p in &d.parametric.parameters { + let r = e.value(&p.name); + rows.push(ParamRow { + name: p.name.clone(), + expr: p.expr.clone(), + value: r.as_ref().ok().copied(), + error: r.err().map(|e| e.to_string()), + kind: "user", + constraint: None, + description: p.description.clone(), + }); + } + rows +} + +/// Is `name` used by a parameter or dimensional constraint? +pub fn name_in_use(d: &Drawing, name: &str) -> bool { + d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name) +} + +/// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …). +pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String { + let prefix = kind.name_prefix(); + (1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x")) +} + +/// Next free constraint id. +pub fn next_id(d: &Drawing) -> u32 { + d.constraints.iter().map(|c| c.id).max().map_or(1, |m| m.saturating_add(1)) +} + +// ---------------- systems ---------------- + +/// One connected component of the constraint graph. +struct Comp { + model: Model, + built: Vec, + weights: Vec, + tol: f64, +} + +impl Comp { + fn locks(&self, skip: Option) -> Vec { + let mut locked = vec![false; self.model.x0.len()]; + for (i, b) in self.built.iter().enumerate() { + if Some(i) == skip || b.form != Form::Lock { + continue; + } + for r in &b.refs { + for j in self.model.lock_indices(r).unwrap_or_default() { + if let Some(l) = locked.get_mut(j) { + *l = true; + } + } + } + } + locked + } + fn free(&self, skip: Option) -> Vec { + self.locks(skip).iter().enumerate().filter(|(_, l)| !**l).map(|(i, _)| i).collect() + } + fn eval(&self, x: &[f64], out: &mut Vec, skip: Option) { + for (i, b) in self.built.iter().enumerate() { + if Some(i) != skip { + b.eval(&self.model, x, out); + } + } + } + fn rows(&self, skip: Option) -> usize { + self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum() + } + fn run(&self, skip: Option) -> Result { + let free = self.free(skip); + let rows = self.rows(skip); + if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 { + return Err(SolveError::TooLarge); + } + let f = |x: &[f64], out: &mut Vec| self.eval(x, out, skip); + Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER)) + } + /// Constraint ids in conflict (each one whose removal makes the rest solvable). + fn conflicts(&self, x_failed: &[f64]) -> Vec { + let mut ids = Vec::new(); + if self.built.len() <= MAX_CONFLICT_PROBES { + for i in 0..self.built.len() { + if let Ok(o) = self.run(Some(i)) + && o.converged + && let Some(b) = self.built.get(i) + { + ids.push(b.id); + } + } + } + if ids.is_empty() { + // Fall back to the constraints left unsatisfied. + for b in &self.built { + let mut out = Vec::new(); + b.eval(&self.model, x_failed, &mut out); + if lm::max_abs(&out) > self.tol * 10.0 { + ids.push(b.id); + } + } + } + ids + } +} + +fn scale_tol(x: &[f64]) -> f64 { + 1e-9 * x.iter().fold(1.0f64, |m, v| m.max(v.abs())) +} + +/// Group constraints into connected components by shared entities. +fn components<'a>(cons: &[&'a Constraint]) -> Vec> { + let mut parent: Vec = (0..cons.len()).collect(); + fn find(p: &mut [usize], mut i: usize) -> usize { + let mut guard = 0; + while let Some(&q) = p.get(i) { + if q == i || guard > 100_000 { + break; + } + let g = p.get(q).copied().unwrap_or(q); + if let Some(slot) = p.get_mut(i) { + *slot = g; + } + i = q; + guard += 1; + } + i + } + let mut owner: HashMap = HashMap::new(); + for (i, c) in cons.iter().enumerate() { + for r in &c.refs { + match owner.get(&r.handle) { + Some(&j) => { + let (a, b) = (find(&mut parent, i), find(&mut parent, j)); + if a != b + && let Some(slot) = parent.get_mut(a) + { + *slot = b; + } + } + None => { + owner.insert(r.handle, i); + } + } + } + } + let mut groups: BTreeMap> = BTreeMap::new(); + for (i, c) in cons.iter().enumerate() { + let root = find(&mut parent, i); + groups.entry(root).or_default().push(c); + } + groups.into_values().collect() +} + +/// Build the component systems. Constraints that cannot be interpreted are returned as skipped. +fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec, Vec<(u32, String)>), SolveError> { + let mut e = env(d); + let mut skipped = Vec::new(); + let mut comps = Vec::new(); + for group in components(cons) { + let handles: Vec = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); + let model = Model::build(d, &handles); + let mut built = Vec::new(); + for c in group { + let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 }; + match Built::new(c, &model, &model.x0, target) { + Ok(b) => built.push(b), + Err(msg) => skipped.push((c.id, msg)), + } + } + if built.is_empty() { + continue; + } + let mut weights = vec![1.0; model.x0.len()]; + for s in &model.slots { + let w = if opts.keep.contains(&s.handle) { + 1e4 + } else if opts.prefer_move.contains(&s.handle) { + 1e-2 + } else { + 1.0 + }; + for j in s.off..s.off + s.len { + if let Some(slot) = weights.get_mut(j) { + *slot = w; + } + } + if s.shape == model::Shape::Arc { + // Angles: weight by r² so the endpoints' movement is what is minimised. + let r2 = model.x0.get(s.off + 2).map_or(1.0, |r| (r * r).max(1e-6)); + for j in [s.off + 3, s.off + 4] { + if let Some(slot) = weights.get_mut(j) { + *slot *= r2; + } + } + } + } + let tol = scale_tol(&model.x0); + comps.push(Comp { model, built, weights, tol }); + } + Ok((comps, skipped)) +} + +/// Solve all of the drawing's constraints and write the geometry back. On failure nothing is +/// changed. +pub fn solve(d: &mut Drawing, opts: &SolveOptions) -> Result { + let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect(); + let (comps, skipped) = build(d, &cons, opts)?; + let mut solutions = Vec::new(); + for c in comps { + let o = c.run(None)?; + if !o.converged { + return Err(SolveError::Conflict { ids: c.conflicts(&o.x) }); + } + solutions.push((c, o.x)); + } + let mut moved = Vec::new(); + for (c, x) in solutions { + moved.extend(c.model.write_back(d, &x)); + } + Ok(SolveReport { moved, skipped }) +} + +/// Largest residual of each constraint at the current geometry (`None` when it cannot be +/// interpreted). +pub fn residuals(d: &Drawing) -> Vec<(u32, Option)> { + let mut e = env(d); + let handles: Vec = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); + let model = Model::build(d, &handles); + d.constraints + .iter() + .take(MAX_CONSTRAINTS) + .map(|c| { + let target = if c.kind.is_dimensional() { constraint_value(&mut e, c).ok() } else { Some(0.0) }; + let r = target.and_then(|t| Built::new(c, &model, &model.x0, t).ok()).map(|b| { + let mut out = Vec::new(); + b.eval(&model, &model.x0, &mut out); + lm::max_abs(&out) + }); + (c.id, r) + }) + .collect() +} + +/// True when every interpretable constraint holds at the current geometry. +pub fn all_satisfied(d: &Drawing) -> bool { + let handles: Vec = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); + let tol = scale_tol(&Model::build(d, &handles).x0) * 10.0; + residuals(d).iter().all(|(_, r)| r.is_none_or(|v| v <= tol)) +} + +/// Check that a constraint's references make sense for its type. +pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> { + let handles: Vec = c.refs.iter().map(|r| r.handle).collect(); + for h in &handles { + match d.entity(*h) { + None => return Err(format!("no such object {}", h.hex())), + Some(e) if !supported(&e.kind) => return Err(format!("{} objects cannot be constrained", e.kind.type_name())), + _ => {} + } + } + let model = Model::build(d, &handles); + Built::new(c, &model, &model.x0, 1.0).map(|_| ()) +} + +/// The current measured value of a dimensional constraint over `refs` (degrees for angular). +pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result { + let c = Constraint { id: 0, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() }; + let handles: Vec = refs.iter().map(|r| r.handle).collect(); + let model = Model::build(d, &handles); + let b = Built::new(&c, &model, &model.x0, 1.0)?; + Ok(b.measure(&model, &model.x0)) +} + +/// Remove constraints whose objects are gone or no longer fit (e.g. a polyline lost vertices). +/// Returns the removed ids. +pub fn purge_invalid(d: &mut Drawing) -> Vec { + let mut gone = Vec::new(); + let keep: Vec = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect(); + let mut i = 0; + d.constraints.retain(|c| { + let k = keep.get(i).copied().unwrap_or(true); + i += 1; + if !k { + gone.push(c.id); + } + k + }); + gone +} + +/// Add a constraint: assigns its id (and name/expression for dimensional constraints when +/// empty), refuses redundant or conflicting constraints, solves and writes the geometry back. +/// On error the drawing is unchanged. +pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result { + if d.constraints.len() >= MAX_CONSTRAINTS { + return Err(SolveError::Other("too many constraints".into())); + } + c.id = next_id(d); + validate(d, &c).map_err(|msg| SolveError::Invalid { id: c.id, msg })?; + if c.kind.is_dimensional() { + if c.name.is_empty() { + c.name = next_name(d, c.kind); + } + if !expr::valid_name(&c.name) { + return Err(SolveError::Other(format!("invalid parameter name `{}`", c.name))); + } + if name_in_use(d, &c.name) { + return Err(SolveError::Other(format!("parameter name `{}` is already used", c.name))); + } + if c.expr.trim().is_empty() { + let v = measure(d, c.kind, &c.refs).map_err(|msg| SolveError::Invalid { id: c.id, msg })?; + c.expr = format_value(v); + } + } else { + c.name.clear(); + c.expr.clear(); + } + let id = c.id; + let mut trial = d.clone(); + trial.constraints.push(c); + // Solve only the component holding the new constraint (others are untouched). + let cons: Vec<&Constraint> = trial.constraints.iter().collect(); + let (comps, skipped) = build(&trial, &cons, opts)?; + if let Some((_, msg)) = skipped.iter().find(|(i, _)| *i == id) { + return Err(SolveError::Invalid { id, msg: msg.clone() }); + } + let Some(comp) = comps.into_iter().find(|k| k.built.iter().any(|b| b.id == id)) else { + return Err(SolveError::Invalid { id, msg: "could not be interpreted".into() }); + }; + let o = comp.run(None)?; + if !o.converged { + // The new constraint first, then the existing ones it fights with. + let mut ids = comp.conflicts(&o.x); + ids.retain(|i| *i != id); + ids.insert(0, id); + return Err(SolveError::Conflict { ids }); + } + // Redundancy: the new rows must raise the rank. + if let Some(pos) = comp.built.iter().position(|b| b.id == id) + && let Some(nb) = comp.built.get(pos) + && nb.rows > 0 + { + let free = comp.free(None); + let mut ordered: Vec<&Built> = comp.built.iter().filter(|b| b.id != id).collect(); + ordered.push(nb); + let f = |x: &[f64], out: &mut Vec| { + for b in &ordered { + b.eval(&comp.model, x, out); + } + }; + let rows_all: usize = ordered.iter().map(|b| b.rows).sum(); + let rows_old = rows_all - nb.rows; + let jac = lm::jacobian(&f, &o.x, &free, rows_all); + let r_all = lm::rank(&jac, rows_all, free.len()); + let r_old = lm::rank(jac.get(..rows_old * free.len()).unwrap_or_default(), rows_old, free.len()); + if r_all < r_old + nb.rows { + let mine: Vec = nb.refs.iter().map(|r| r.handle).collect(); + let ids = comp.built.iter().filter(|b| b.id != id && b.refs.iter().any(|r| mine.contains(&r.handle))).map(|b| b.id).collect(); + return Err(SolveError::Redundant { ids }); + } + } + comp.model.write_back(&mut trial, &o.x); + *d = trial; + Ok(id) +} + +/// Format a value for an expression (up to 6 decimals, trailing zeros trimmed). +pub fn format_value(v: f64) -> String { + let s = format!("{v:.6}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } +} + +/// Set a parameter's expression (dimensional constraint name or user parameter; a new name +/// creates a user parameter), then re-solve. On error the drawing is unchanged. +pub fn set_parameter(d: &mut Drawing, name: &str, expr_text: &str, opts: &SolveOptions) -> Result { + if !expr::valid_name(name) { + return Err(SolveError::Other(format!("invalid parameter name `{name}`"))); + } + expr::parse(expr_text).map_err(|err| SolveError::Expr { name: name.into(), err })?; + let mut trial = d.clone(); + if let Some(c) = trial.constraints.iter_mut().find(|c| c.name == name) { + c.expr = expr_text.trim().to_string(); + } else if let Some(p) = trial.parametric.parameters.iter_mut().find(|p| p.name == name) { + p.expr = expr_text.trim().to_string(); + } else { + trial.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: expr_text.trim().into(), description: String::new() }); + } + // Every expression must still evaluate. + for row in parameter_table(&trial) { + if let Some(err) = row.error { + return Err(SolveError::Other(format!("parameter `{}`: {err}", row.name))); + } + } + let report = solve(&mut trial, opts)?; + *d = trial; + Ok(report) +} + +/// Where to draw a constraint's glyphs (one anchor per reference). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Glyph { + pub id: u32, + pub kind: &'static str, + pub dimensional: bool, + pub handles: Vec, + pub anchors: Vec, + pub name: String, + pub expr: String, + pub value: Option, + pub satisfied: bool, +} + +/// Glyph anchors for every constraint (for the constraint bars / dynamic constraints UI). +pub fn glyphs(d: &Drawing) -> Vec { + let handles: Vec = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); + let model = Model::build(d, &handles); + let x = &model.x0; + let tol = scale_tol(x) * 10.0; + let res: HashMap> = residuals(d).into_iter().collect(); + let mut e = env(d); + d.constraints + .iter() + .take(MAX_CONSTRAINTS) + .map(|c| { + let anchors = c + .refs + .iter() + .filter_map(|r| { + model + .point(x, r) + .or_else(|| model.line(x, r).map(|(a, b)| a.mid(b))) + .or_else(|| model.circle(x, r).map(|(c, rad)| c + Vec2::new(0.0, rad))) + }) + .collect(); + Glyph { + id: c.id, + kind: c.kind.name(), + dimensional: c.kind.is_dimensional(), + handles: c.refs.iter().map(|r| r.handle.hex()).collect(), + anchors, + name: c.name.clone(), + expr: c.expr.clone(), + value: if c.kind.is_dimensional() { constraint_value(&mut e, c).ok() } else { None }, + satisfied: res.get(&c.id).copied().flatten().is_some_and(|v| v <= tol), + } + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/constraints/src/lm.rs b/crates/constraints/src/lm.rs new file mode 100644 index 00000000..b619a44a --- /dev/null +++ b/crates/constraints/src/lm.rs @@ -0,0 +1,271 @@ +//! Damped, weighted minimum-norm Gauss–Newton (Levenberg–Marquardt style) with a numerical +//! Jacobian. +//! +//! Each step solves `min ‖W^{1/2} Δx‖` subject to `J Δx = −r` (damped): +//! `Δx = −W⁻¹ Jᵀ (J W⁻¹ Jᵀ + μ I)⁻¹ r`. Under-constrained systems therefore move as little as +//! possible, and heavily weighted parameters (geometry the user just edited, or the first object +//! picked) move least. + +/// Most residual rows one solve may have (the normal matrix is rows × rows). +pub const MAX_ROWS: usize = 800; + +pub(crate) struct Outcome { + pub x: Vec, + pub converged: bool, +} + +pub(crate) fn max_abs(v: &[f64]) -> f64 { + v.iter().fold(0.0f64, |m, a| if a.is_finite() { m.max(a.abs()) } else { f64::INFINITY }) +} + +fn norm2(v: &[f64]) -> f64 { + v.iter().map(|a| a * a).sum::() +} + +/// Numerical Jacobian over the free parameters (row-major, rows × free.len()). +pub(crate) fn jacobian(f: &dyn Fn(&[f64], &mut Vec), x: &[f64], free: &[usize], rows: usize) -> Vec { + let nf = free.len(); + let mut jac = vec![0.0; rows * nf]; + let mut xp = x.to_vec(); + let (mut rp, mut rm) = (Vec::with_capacity(rows), Vec::with_capacity(rows)); + for (k, &j) in free.iter().enumerate() { + let Some(&xj) = x.get(j) else { continue }; + let h = 1e-7 * xj.abs().max(1.0); + if let Some(v) = xp.get_mut(j) { + *v = xj + h; + } + rp.clear(); + f(&xp, &mut rp); + if let Some(v) = xp.get_mut(j) { + *v = xj - h; + } + rm.clear(); + f(&xp, &mut rm); + if let Some(v) = xp.get_mut(j) { + *v = xj; + } + for i in 0..rows { + let d = (rp.get(i).copied().unwrap_or(0.0) - rm.get(i).copied().unwrap_or(0.0)) / (2.0 * h); + if let Some(slot) = jac.get_mut(i * nf + k) { + *slot = if d.is_finite() { d } else { 0.0 }; + } + } + } + jac +} + +/// Solve the dense system `a y = b` (n × n, row-major) by Gaussian elimination with partial +/// pivoting. `None` when singular. +fn solve_dense(mut a: Vec, mut b: Vec, n: usize) -> Option> { + let scale = a.iter().fold(0.0f64, |m, v| m.max(v.abs())).max(1e-300); + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for r in col..n { + let v = a.get(r * n + col)?.abs(); + if v > best { + best = v; + piv = r; + } + } + if best <= scale * 1e-15 { + return None; + } + if piv != col { + for c in 0..n { + a.swap(piv * n + c, col * n + c); + } + b.swap(piv, col); + } + let p = *a.get(col * n + col)?; + for r in col + 1..n { + let f = *a.get(r * n + col)? / p; + if f == 0.0 { + continue; + } + for c in col..n { + let v = *a.get(col * n + c)?; + *a.get_mut(r * n + c)? -= f * v; + } + let bv = *b.get(col)?; + *b.get_mut(r)? -= f * bv; + } + } + let mut y = vec![0.0; n]; + for r in (0..n).rev() { + let mut s = *b.get(r)?; + for c in r + 1..n { + s -= a.get(r * n + c)? * y.get(c)?; + } + *y.get_mut(r)? = s / a.get(r * n + r)?; + } + y.iter().all(|v| v.is_finite()).then_some(y) +} + +/// Numerical rank of a row-major `rows × cols` matrix (rows normalised first). +pub(crate) fn rank(m: &[f64], rows: usize, cols: usize) -> usize { + let mut a: Vec> = (0..rows) + .map(|i| { + let r: Vec = m.get(i * cols..(i + 1) * cols).map(<[f64]>::to_vec).unwrap_or_default(); + let n = norm2(&r).sqrt(); + if n > 1e-12 { r.iter().map(|v| v / n).collect() } else { vec![0.0; cols] } + }) + .collect(); + let mut rank = 0; + let mut used = vec![false; rows]; + for c in 0..cols { + let mut piv = None; + let mut best = 1e-7; + for (i, row) in a.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + let v = row.get(c).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = Some(i); + } + } + let Some(p) = piv else { continue }; + if let Some(u) = used.get_mut(p) { + *u = true; + } + rank += 1; + let prow = a.get(p).cloned().unwrap_or_default(); + let pv = prow.get(c).copied().unwrap_or(1.0); + for (i, row) in a.iter_mut().enumerate() { + if i == p || used.get(i).copied().unwrap_or(true) { + continue; + } + let f = row.get(c).copied().unwrap_or(0.0) / pv; + if f != 0.0 { + for (v, pr) in row.iter_mut().zip(&prow) { + *v -= f * pr; + } + } + } + } + rank +} + +/// Run the solver from `x0`. Only `free` parameters change. +pub(crate) fn solve(f: &dyn Fn(&[f64], &mut Vec), x0: &[f64], free: &[usize], weights: &[f64], tol: f64, max_iter: usize) -> Outcome { + let mut x = x0.to_vec(); + let mut r = Vec::new(); + f(&x, &mut r); + let rows = r.len(); + if rows == 0 || max_abs(&r) <= tol { + return Outcome { x, converged: true }; + } + if free.is_empty() || rows > MAX_ROWS { + return Outcome { x, converged: false }; + } + let nf = free.len(); + let winv: Vec = free.iter().map(|&j| 1.0 / weights.get(j).copied().unwrap_or(1.0).max(1e-12)).collect(); + let mut mu = 1e-10; + let mut cost = norm2(&r); + let mut iter = 0; + let mut rn = Vec::with_capacity(rows); + while iter < max_iter { + iter += 1; + let jac = jacobian(f, &x, free, rows); + // A = J W⁻¹ Jᵀ + let mut a = vec![0.0; rows * rows]; + for i in 0..rows { + for k in i..rows { + let mut s = 0.0; + for (c, w) in winv.iter().enumerate() { + s += jac.get(i * nf + c).copied().unwrap_or(0.0) * jac.get(k * nf + c).copied().unwrap_or(0.0) * w; + } + if let Some(v) = a.get_mut(i * rows + k) { + *v = s; + } + if let Some(v) = a.get_mut(k * rows + i) { + *v = s; + } + } + } + let diag_max = (0..rows).map(|i| a.get(i * rows + i).copied().unwrap_or(0.0)).fold(0.0f64, f64::max).max(1e-12); + let mut improved = false; + for _ in 0..12 { + let mut ad = a.clone(); + for i in 0..rows { + if let Some(v) = ad.get_mut(i * rows + i) { + *v += mu * diag_max; + } + } + let Some(y) = solve_dense(ad, r.clone(), rows) else { + mu *= 10.0; + continue; + }; + let mut xn = x.clone(); + for (c, &j) in free.iter().enumerate() { + let mut s = 0.0; + for (i, yi) in y.iter().enumerate() { + s += jac.get(i * nf + c).copied().unwrap_or(0.0) * yi; + } + if let Some(v) = xn.get_mut(j) { + *v -= winv.get(c).copied().unwrap_or(1.0) * s; + } + } + rn.clear(); + f(&xn, &mut rn); + let cn = norm2(&rn); + if cn.is_finite() && cn < cost { + x = xn; + std::mem::swap(&mut r, &mut rn); + cost = cn; + mu = (mu * 0.1).max(1e-15); + improved = true; + break; + } + mu *= 10.0; + } + if max_abs(&r) <= tol { + return Outcome { x, converged: true }; + } + if !improved { + break; + } + } + let converged = max_abs(&r) <= tol; + Outcome { x, converged } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dense_and_rank() { + let y = solve_dense(vec![2.0, 1.0, 1.0, 3.0], vec![3.0, 5.0], 2).unwrap(); + assert!((y[0] - 0.8).abs() < 1e-12 && (y[1] - 1.4).abs() < 1e-12); + assert!(solve_dense(vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 1.0], 2).is_none()); + assert_eq!(rank(&[1.0, 0.0, 0.0, 1.0, 1.0, 1.0], 3, 2), 2); + assert_eq!(rank(&[1.0, 2.0, 2.0, 4.0], 2, 2), 1); + assert_eq!(rank(&[0.0; 4], 2, 2), 0); + } + + #[test] + fn minimum_norm_step() { + // One equation x + y = 2 from (0,0): the minimum-norm solution is (1,1). + let f = |x: &[f64], out: &mut Vec| out.push(x[0] + x[1] - 2.0); + let o = solve(&f, &[0.0, 0.0], &[0, 1], &[1.0, 1.0], 1e-12, 50); + assert!(o.converged); + assert!((o.x[0] - 1.0).abs() < 1e-9 && (o.x[1] - 1.0).abs() < 1e-9); + // Heavy weight on x keeps it in place. + let o = solve(&f, &[0.0, 0.0], &[0, 1], &[1e6, 1.0], 1e-12, 50); + assert!(o.x[0].abs() < 1e-5 && (o.x[1] - 2.0).abs() < 1e-5); + } + + #[test] + fn inconsistent_does_not_converge() { + let f = |x: &[f64], out: &mut Vec| { + out.push(x[0] - 1.0); + out.push(x[0] - 2.0); + }; + let o = solve(&f, &[0.0], &[0], &[1.0], 1e-10, 100); + assert!(!o.converged); + assert!(o.x[0].is_finite()); + } +} diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs new file mode 100644 index 00000000..6425071a --- /dev/null +++ b/crates/constraints/src/model.rs @@ -0,0 +1,260 @@ +//! Maps constrained entities to a flat parameter vector and back. +//! +//! - Line: `ax ay bx by` +//! - Circle: `cx cy r` +//! - Arc: `cx cy r start end` (angles in radians) +//! - LwPolyline: `x0 y0 x1 y1 …` (bulges are kept; segments are treated as straight lines) +//! - Point: `x y` + +use std::collections::HashMap; + +use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Sub}; +use cadcraft_geom::{Vec2, ccw_sweep}; + +/// Most parameters one solve may have. +pub const MAX_PARAMS: usize = 4000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Shape { + Line, + Circle, + Arc, + Poly { n: usize, closed: bool }, + Point, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Slot { + pub handle: Handle, + pub shape: Shape, + pub off: usize, + pub len: usize, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct Model { + pub slots: Vec, + pub x0: Vec, + index: HashMap, +} + +/// Can this entity take part in constraints? +pub fn supported(kind: &EntityKind) -> bool { + matches!(kind, EntityKind::Line(_) | EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_) | EntityKind::Point(_)) +} + +fn p2(x: &[f64], i: usize) -> Option { + Some(Vec2::new(*x.get(i)?, *x.get(i + 1)?)) +} + +impl Model { + /// Build a model for the given entities. Unsupported or missing entities are skipped. + pub fn build(d: &Drawing, handles: &[Handle]) -> Model { + let mut m = Model::default(); + for &h in handles { + if m.index.contains_key(&h) { + continue; + } + let Some(e) = d.entity(h) else { continue }; + let off = m.x0.len(); + let shape = match &e.kind { + EntityKind::Line(l) => { + m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]); + Shape::Line + } + EntityKind::Circle(c) => { + m.x0.extend([c.center.x, c.center.y, c.radius]); + Shape::Circle + } + EntityKind::Arc(a) => { + m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]); + Shape::Arc + } + EntityKind::LwPolyline(p) => { + if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS { + continue; + } + for v in &p.vertices { + m.x0.extend([v.p.x, v.p.y]); + } + Shape::Poly { n: p.vertices.len(), closed: p.closed } + } + EntityKind::Point(p) => { + m.x0.extend([p.p.x, p.p.y]); + Shape::Point + } + _ => continue, + }; + if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) { + m.x0.truncate(off); + continue; + } + let len = m.x0.len() - off; + m.index.insert(h, m.slots.len()); + m.slots.push(Slot { handle: h, shape, off, len }); + } + m + } + + pub fn slot(&self, h: Handle) -> Option<&Slot> { + self.slots.get(*self.index.get(&h)?) + } + + /// A point-like reference. + pub fn point(&self, x: &[f64], r: &GeomRef) -> Option { + let s = self.slot(r.handle)?; + let o = s.off; + match (s.shape, r.sub) { + (Shape::Line, Sub::Start) => p2(x, o), + (Shape::Line, Sub::End) => p2(x, o + 2), + (Shape::Line, Sub::Mid) => Some(p2(x, o)?.mid(p2(x, o + 2)?)), + (Shape::Circle | Shape::Arc, Sub::Center) => p2(x, o), + (Shape::Arc, Sub::Start | Sub::End | Sub::Mid) => { + let c = p2(x, o)?; + let rad = *x.get(o + 2)?; + let (a0, a1) = (*x.get(o + 3)?, *x.get(o + 4)?); + let a = match r.sub { + Sub::Start => a0, + Sub::End => a1, + _ => a0 + ccw_sweep(a0, a1) / 2.0, + }; + Some(Vec2::polar(c, rad, a)) + } + (Shape::Poly { n, .. }, Sub::Vertex(i)) => { + let i = i as usize; + if i < n { p2(x, o + 2 * i) } else { None } + } + (Shape::Poly { .. }, Sub::Start) => p2(x, o), + (Shape::Poly { n, .. }, Sub::End) => p2(x, o + 2 * n.checked_sub(1)?), + (Shape::Poly { .. }, Sub::Mid) => None, + (Shape::Point, Sub::Whole | Sub::Start | Sub::Center) => p2(x, o), + _ => None, + } + } + + /// Vertex indices of a polyline segment. + fn seg(n: usize, closed: bool, i: usize) -> Option<(usize, usize)> { + if i + 1 < n { + Some((i, i + 1)) + } else if closed && i + 1 == n && n > 2 { + Some((i, 0)) + } else { + None + } + } + + /// A line-like reference (line or polyline segment). + pub fn line(&self, x: &[f64], r: &GeomRef) -> Option<(Vec2, Vec2)> { + let s = self.slot(r.handle)?; + let o = s.off; + match (s.shape, r.sub) { + (Shape::Line, Sub::Whole) => Some((p2(x, o)?, p2(x, o + 2)?)), + (Shape::Poly { n, closed }, Sub::Segment(i)) => { + let (a, b) = Self::seg(n, closed, i as usize)?; + Some((p2(x, o + 2 * a)?, p2(x, o + 2 * b)?)) + } + (Shape::Poly { n: 2, .. }, Sub::Whole) => Some((p2(x, o)?, p2(x, o + 2)?)), + _ => None, + } + } + + /// A circle-like reference (circle or arc). + pub fn circle(&self, x: &[f64], r: &GeomRef) -> Option<(Vec2, f64)> { + let s = self.slot(r.handle)?; + match (s.shape, r.sub) { + (Shape::Circle | Shape::Arc, Sub::Whole) => Some((p2(x, s.off)?, *x.get(s.off + 2)?)), + _ => None, + } + } + + /// Arc sweep (radians) of an arc reference. + pub fn arc_sweep(&self, x: &[f64], r: &GeomRef) -> Option { + let s = self.slot(r.handle)?; + match (s.shape, r.sub) { + (Shape::Arc, Sub::Whole) => Some(ccw_sweep(*x.get(s.off + 3)?, *x.get(s.off + 4)?)), + _ => None, + } + } + + /// Parameters a Fix on this reference locks directly. `None` when the reference is a derived + /// point (fixed through a residual instead). + pub fn lock_indices(&self, r: &GeomRef) -> Option> { + let s = self.slot(r.handle)?; + let o = s.off; + let v = match (s.shape, r.sub) { + (_, Sub::Whole) => (o..o + s.len).collect(), + (Shape::Line, Sub::Start) => vec![o, o + 1], + (Shape::Line, Sub::End) => vec![o + 2, o + 3], + (Shape::Circle | Shape::Arc, Sub::Center) => vec![o, o + 1], + (Shape::Poly { n, .. }, Sub::Vertex(i)) if (i as usize) < n => vec![o + 2 * i as usize, o + 2 * i as usize + 1], + (Shape::Poly { .. }, Sub::Start) => vec![o, o + 1], + (Shape::Poly { n, .. }, Sub::End) if n > 0 => vec![o + 2 * (n - 1), o + 2 * (n - 1) + 1], + (Shape::Poly { n, closed }, Sub::Segment(i)) => { + let (a, b) = Self::seg(n, closed, i as usize)?; + vec![o + 2 * a, o + 2 * a + 1, o + 2 * b, o + 2 * b + 1] + } + _ => return None, + }; + Some(v) + } + + /// Write solved parameters back. Returns the handles that moved. + pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec { + let mut moved = Vec::new(); + for s in &self.slots { + let (Some(new), Some(old)) = (x.get(s.off..s.off + s.len), self.x0.get(s.off..s.off + s.len)) else { continue }; + if new.iter().any(|v| !v.is_finite()) { + continue; + } + let scale = old.iter().fold(1.0f64, |m, v| m.max(v.abs())); + if new.iter().zip(old).all(|(a, b)| (a - b).abs() <= 1e-13 * scale) { + continue; + } + let shape = s.shape; + let ok = d.modify_entity(s.handle, |e| match (&mut e.kind, shape) { + (EntityKind::Line(l), Shape::Line) => { + if let [ax, ay, bx, by] = new { + l.a.x = *ax; + l.a.y = *ay; + l.b.x = *bx; + l.b.y = *by; + } + } + (EntityKind::Circle(c), Shape::Circle) => { + if let [cx, cy, r] = new { + c.center.x = *cx; + c.center.y = *cy; + c.radius = r.abs().max(1e-12); + } + } + (EntityKind::Arc(a), Shape::Arc) => { + if let [cx, cy, r, s0, s1] = new { + a.center.x = *cx; + a.center.y = *cy; + a.radius = r.abs().max(1e-12); + a.start = cadcraft_geom::norm_angle(*s0); + a.end = cadcraft_geom::norm_angle(*s1); + } + } + (EntityKind::LwPolyline(p), Shape::Poly { n, .. }) if p.vertices.len() == n => { + for (i, v) in p.vertices.iter_mut().enumerate() { + if let (Some(px), Some(py)) = (new.get(2 * i), new.get(2 * i + 1)) { + v.p = Vec2::new(*px, *py); + } + } + } + (EntityKind::Point(p), Shape::Point) => { + if let [px, py] = new { + p.p.x = *px; + p.p.y = *py; + } + } + _ => {} + }); + if ok.is_ok() { + moved.push(s.handle); + } + } + moved + } +} diff --git a/crates/constraints/src/residual.rs b/crates/constraints/src/residual.rs new file mode 100644 index 00000000..eb2131a6 --- /dev/null +++ b/crates/constraints/src/residual.rs @@ -0,0 +1,392 @@ +//! Residual equations for each constraint type. + +use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef}; +use cadcraft_geom::Vec2; + +use crate::model::Model; + +/// How a constraint's references were interpreted (decided once from the starting geometry). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Form { + /// Point–point. + PP, + /// Point on/at a line (refs: point, line). + PL { + swap: bool, + }, + /// Point on/at a circle (refs: point, circle). + PC { + swap: bool, + }, + /// One line (or two points for horizontal/vertical). + L1, + /// Two points (horizontal/vertical/distance). + P2, + LL, + CC, + /// Line–circle (tangent). + LC { + swap: bool, + }, + /// Symmetric line endpoints, with endpoint pairing crossed or not. + SymLines { + crossed: bool, + }, + /// Circle or arc alone. + C1, + /// Arc sweep (angular on an arc). + ArcSweep, + /// Fix of a derived point (residual to an anchor). + Anchor(Vec2), + /// Fix handled by locking parameters (no rows). + Lock, +} + +#[derive(Clone, Debug)] +pub(crate) struct Built { + pub id: u32, + pub kind: ConstraintKind, + pub refs: Vec, + pub form: Form, + /// Target value (lengths in drawing units, angles in radians). + pub target: f64, + /// Orientation chosen from the starting geometry (±1). + pub sign: f64, + pub rows: usize, +} + +fn dir_n(a: Vec2, b: Vec2) -> Vec2 { + let d = b - a; + let l = d.len(); + if l > 1e-12 { d * (1.0 / l) } else { d } +} + +fn sgn(v: f64) -> f64 { + if v < 0.0 { -1.0 } else { 1.0 } +} + +fn wrap(a: f64) -> f64 { + let t = std::f64::consts::TAU; + let r = (a + std::f64::consts::PI).rem_euclid(t) - std::f64::consts::PI; + if r.is_finite() { r } else { 0.0 } +} + +fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> { + c.refs.get(i) +} + +impl Built { + /// Interpret a constraint against the model at `x`. `target` is the evaluated value for + /// dimensional constraints (degrees for angular). Errors explain what references are needed. + pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result { + use ConstraintKind as K; + let refs = c.refs.clone(); + let pt = |i: usize| r_at(c, i).and_then(|r| m.point(x, r)); + let ln = |i: usize| r_at(c, i).and_then(|r| m.line(x, r)); + let ci = |i: usize| r_at(c, i).and_then(|r| m.circle(x, r)); + let n = c.refs.len(); + let mut b = Built { id: c.id, kind: c.kind, refs, form: Form::PP, target, sign: 1.0, rows: 0 }; + let need = |what: &str| Err(format!("{} needs {what}", c.kind.name())); + match c.kind { + K::Coincident => { + if n != 2 { + return need("two references"); + } + b.form = if pt(0).is_some() && pt(1).is_some() { + b.rows = 2; + Form::PP + } else if pt(0).is_some() && ln(1).is_some() { + Form::PL { swap: false } + } else if pt(1).is_some() && ln(0).is_some() { + Form::PL { swap: true } + } else if pt(0).is_some() && ci(1).is_some() { + Form::PC { swap: false } + } else if pt(1).is_some() && ci(0).is_some() { + Form::PC { swap: true } + } else { + return need("two points, or a point and a line/circle/arc"); + }; + if b.rows == 0 { + b.rows = 1; + } + } + K::Horizontal | K::Vertical => { + if n == 1 && ln(0).is_some() { + b.form = Form::L1; + } else if n == 2 && pt(0).is_some() && pt(1).is_some() { + b.form = Form::P2; + } else { + return need("a line or two points"); + } + b.rows = 1; + } + K::Parallel | K::Perpendicular | K::Collinear => { + if !(n == 2 && ln(0).is_some() && ln(1).is_some()) { + return need("two lines"); + } + b.form = Form::LL; + b.rows = if c.kind == K::Collinear { 2 } else { 1 }; + } + K::Concentric => { + let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| pt(i)); + if !(n == 2 && center(0).is_some() && center(1).is_some() && (ci(0).is_some() || ci(1).is_some())) { + return need("two circles or arcs"); + } + b.form = Form::CC; + b.rows = 2; + } + K::Equal => { + if n != 2 { + return need("two objects"); + } + b.form = if ln(0).is_some() && ln(1).is_some() { + Form::LL + } else if ci(0).is_some() && ci(1).is_some() { + Form::CC + } else { + return need("two lines or two circles/arcs"); + }; + b.rows = 1; + } + K::Tangent | K::Smooth => { + if n != 2 { + return need("two objects"); + } + b.rows = 1; + if let (Some((a, bb)), Some((cc, _))) = (ln(0), ci(1)) { + b.form = Form::LC { swap: false }; + b.sign = sgn(dir_n(a, bb).cross(cc - a)); + } else if let (Some((a, bb)), Some((cc, _))) = (ln(1), ci(0)) { + b.form = Form::LC { swap: true }; + b.sign = sgn(dir_n(a, bb).cross(cc - a)); + } else if let (Some((c1, r1)), Some((c2, r2))) = (ci(0), ci(1)) { + b.form = Form::CC; + // sign > 0: external tangency; < 0: internal. + let d = c1.dist(c2); + b.sign = if d >= r1.abs().max(r2.abs()) { 1.0 } else { -1.0 }; + b.target = sgn(r1 - r2); + } else if ln(0).is_some() && ln(1).is_some() && c.kind == K::Smooth { + b.form = Form::LL; + } else { + return need("a line and a circle/arc, or two circles/arcs"); + } + } + K::Symmetric => { + if n != 3 || ln(2).is_none() { + return need("two objects and a symmetry line"); + } + if pt(0).is_some() && pt(1).is_some() { + b.form = Form::PP; + b.rows = 2; + } else if let (Some((a1, b1)), Some((a2, b2)), Some((la, lb))) = (ln(0), ln(1), ln(2)) { + let straight = a1.mirror(la, lb).dist(a2) + b1.mirror(la, lb).dist(b2); + let crossed = a1.mirror(la, lb).dist(b2) + b1.mirror(la, lb).dist(a2); + b.form = Form::SymLines { crossed: crossed < straight }; + b.rows = 4; + } else if ci(0).is_some() && ci(1).is_some() { + b.form = Form::CC; + b.rows = 3; + } else { + return need("two points, lines or circles and a symmetry line"); + } + } + K::Fix => { + if n != 1 { + return need("one reference"); + } + let r = r_at(c, 0).ok_or_else(|| "Fix needs one reference".to_string())?; + if m.lock_indices(r).is_some() { + b.form = Form::Lock; + b.rows = 0; + } else if let Some(p) = pt(0) { + b.form = Form::Anchor(p); + b.rows = 2; + } else { + return need("a point or an object"); + } + } + K::Distance(axis) => { + b.rows = 1; + if n == 1 && ln(0).is_some() { + b.form = Form::L1; + } else if n == 2 && pt(0).is_some() && pt(1).is_some() { + b.form = Form::P2; + } else if n == 2 && axis == DistAxis::Aligned && pt(0).is_some() && ln(1).is_some() { + b.form = Form::PL { swap: false }; + } else if n == 2 && axis == DistAxis::Aligned && pt(1).is_some() && ln(0).is_some() { + b.form = Form::PL { swap: true }; + } else { + return need("a line or two points"); + } + if axis != DistAxis::Aligned || matches!(b.form, Form::PL { .. }) { + // Keep the current orientation: measure the signed value. + b.sign = 1.0; + let mut out = Vec::new(); + b.target = 0.0; + b.eval(m, x, &mut out); + b.sign = sgn(out.first().copied().unwrap_or(0.0)); + b.target = target; + } + if !(target.is_finite() && target >= 0.0) { + return Err(format!("{} value must be non-negative", c.kind.name())); + } + } + K::Angular => { + b.rows = 1; + if n == 2 && ln(0).is_some() && ln(1).is_some() { + b.form = Form::LL; + let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") }; + let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2)); + b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2))); + } else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() { + b.form = Form::ArcSweep; + } else { + return need("two lines or an arc"); + } + b.target = target.to_radians(); + if !b.target.is_finite() || b.target.abs() > std::f64::consts::TAU { + return Err("angle out of range".into()); + } + } + K::Radius | K::Diameter => { + if !(n == 1 && ci(0).is_some()) { + return need("a circle or arc"); + } + b.form = Form::C1; + b.rows = 1; + if !(target.is_finite() && target > 0.0) { + return Err(format!("{} value must be positive", c.kind.name())); + } + } + } + Ok(b) + } + + /// Append this constraint's residuals (exactly `rows` values). + pub fn eval(&self, m: &Model, x: &[f64], out: &mut Vec) { + let start = out.len(); + self.eval_inner(m, x, out); + out.resize(start + self.rows, 0.0); + } + + fn eval_inner(&self, m: &Model, x: &[f64], out: &mut Vec) -> Option<()> { + use ConstraintKind as K; + let r = |i: usize| self.refs.get(i); + let pt = |i: usize| m.point(x, r(i)?); + let ln = |i: usize| m.line(x, r(i)?); + let ci = |i: usize| m.circle(x, r(i)?); + match (self.kind, self.form) { + (_, Form::Lock) => {} + (_, Form::Anchor(a)) => { + let p = pt(0)?; + out.extend([p.x - a.x, p.y - a.y]); + } + (K::Coincident, Form::PP) => { + let (p, q) = (pt(0)?, pt(1)?); + out.extend([p.x - q.x, p.y - q.y]); + } + (K::Coincident, Form::PL { swap }) => { + let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) }; + out.push(dir_n(a, b).cross(p - a)); + } + (K::Coincident, Form::PC { swap }) => { + let (p, (c, rad)) = if swap { (pt(1)?, ci(0)?) } else { (pt(0)?, ci(1)?) }; + out.push(p.dist(c) - rad); + } + (K::Horizontal | K::Vertical, f) => { + let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) }; + out.push(if self.kind == K::Horizontal { a.y - b.y } else { a.x - b.x }); + } + (K::Parallel | K::Smooth, Form::LL) => { + let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); + out.push(dir_n(a1, b1).cross(dir_n(a2, b2))); + } + (K::Perpendicular, _) => { + let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); + out.push(dir_n(a1, b1).dot(dir_n(a2, b2))); + } + (K::Collinear, _) => { + let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); + let d = dir_n(a1, b1); + out.extend([d.cross(a2 - a1), d.cross(b2 - a1)]); + } + (K::Concentric, _) => { + let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| pt(i)); + let (p, q) = (center(0)?, center(1)?); + out.extend([p.x - q.x, p.y - q.y]); + } + (K::Equal, Form::LL) => { + let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); + out.push(a1.dist(b1) - a2.dist(b2)); + } + (K::Equal, _) => { + let ((_, r1), (_, r2)) = (ci(0)?, ci(1)?); + out.push(r1 - r2); + } + (K::Tangent | K::Smooth, Form::LC { swap }) => { + let ((a, b), (c, rad)) = if swap { (ln(1)?, ci(0)?) } else { (ln(0)?, ci(1)?) }; + out.push(self.sign * dir_n(a, b).cross(c - a) - rad); + } + (K::Tangent | K::Smooth, _) => { + let ((c1, r1), (c2, r2)) = (ci(0)?, ci(1)?); + let d = c1.dist(c2); + out.push(if self.sign > 0.0 { d - (r1 + r2) } else { d - self.target * (r1 - r2) }); + } + (K::Symmetric, Form::PP) => { + let (p, q, (a, b)) = (pt(0)?, pt(1)?, ln(2)?); + let d = dir_n(a, b); + out.extend([d.cross(p.mid(q) - a), d.dot(q - p)]); + } + (K::Symmetric, Form::SymLines { crossed }) => { + let ((a1, b1), (a2, b2), (a, b)) = (ln(0)?, ln(1)?, ln(2)?); + let (a2, b2) = if crossed { (b2, a2) } else { (a2, b2) }; + let d = dir_n(a, b); + out.extend([d.cross(a1.mid(a2) - a), d.dot(a2 - a1), d.cross(b1.mid(b2) - a), d.dot(b2 - b1)]); + } + (K::Symmetric, _) => { + let ((p, r1), (q, r2), (a, b)) = (ci(0)?, ci(1)?, ln(2)?); + let d = dir_n(a, b); + out.extend([d.cross(p.mid(q) - a), d.dot(q - p), r1 - r2]); + } + (K::Distance(axis), f) => { + let v = match (axis, f) { + (_, Form::PL { swap }) => { + let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) }; + dir_n(a, b).cross(p - a) + } + _ => { + let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) }; + match axis { + DistAxis::Aligned => a.dist(b), + DistAxis::Horizontal => b.x - a.x, + DistAxis::Vertical => b.y - a.y, + } + } + }; + out.push(self.sign * v - self.target); + } + (K::Angular, Form::ArcSweep) => { + out.push(m.arc_sweep(x, r(0)?)? - self.target); + } + (K::Angular, _) => { + let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); + let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2)); + out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target)); + } + (K::Radius, _) => out.push(ci(0)?.1 - self.target), + (K::Diameter, _) => out.push(2.0 * ci(0)?.1 - self.target), + _ => {} + } + Some(()) + } + + /// The current measured value (degrees for angular), for dimensional constraints. + pub fn measure(&self, m: &Model, x: &[f64]) -> f64 { + let mut b = self.clone(); + b.target = 0.0; + let mut out = Vec::new(); + b.eval(m, x, &mut out); + let v = out.first().copied().unwrap_or(0.0).abs(); + if self.kind == ConstraintKind::Angular { v.to_degrees() } else { v } + } +} diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs new file mode 100644 index 00000000..182adec4 --- /dev/null +++ b/crates/constraints/src/tests.rs @@ -0,0 +1,435 @@ +use cadcraft_doc::{Arc as DArc, Circle as DCircle, Common, DistAxis, EntityKind, Line as DLine, LwPolyline, Point as DPoint, Space, Sub}; +use cadcraft_geom::{PolyVertex, Vec3}; + +use super::*; + +fn v(x: f64, y: f64) -> Vec3 { + Vec3::new(x, y, 0.0) +} +fn add_e(d: &mut Drawing, k: EntityKind) -> Handle { + d.add(&Space::Model, Common::default(), k).unwrap() +} +fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) -> Handle { + add_e(d, EntityKind::Line(DLine { a: v(a.0, a.1), b: v(b.0, b.1) })) +} +fn circle(d: &mut Drawing, c: (f64, f64), r: f64) -> Handle { + add_e(d, EntityKind::Circle(DCircle { center: v(c.0, c.1), radius: r })) +} +fn arc(d: &mut Drawing, c: (f64, f64), r: f64, s: f64, e: f64) -> Handle { + add_e(d, EntityKind::Arc(DArc { center: v(c.0, c.1), radius: r, start: s, end: e })) +} +fn con(kind: ConstraintKind, refs: &[GeomRef]) -> Constraint { + Constraint { id: 0, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() } +} +fn dim(kind: ConstraintKind, refs: &[GeomRef], name: &str, expr: &str) -> Constraint { + Constraint { id: 0, kind, refs: refs.to_vec(), name: name.into(), expr: expr.into() } +} +fn w(h: Handle) -> GeomRef { + GeomRef::whole(h) +} +fn s(h: Handle, sub: Sub) -> GeomRef { + GeomRef::new(h, sub) +} +fn ln(d: &Drawing, h: Handle) -> (Vec2, Vec2) { + match &d.entity(h).unwrap().kind { + EntityKind::Line(l) => (l.a.xy(), l.b.xy()), + _ => panic!(), + } +} +fn circ(d: &Drawing, h: Handle) -> (Vec2, f64) { + match &d.entity(h).unwrap().kind { + EntityKind::Circle(c) => (c.center.xy(), c.radius), + EntityKind::Arc(c) => (c.center.xy(), c.radius), + _ => panic!(), + } +} +fn ok(d: &mut Drawing, c: Constraint) -> u32 { + add(d, c, &SolveOptions::default()).unwrap() +} +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn coincident_points_and_point_on_curve() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); + let b = line(&mut d, (10.3, 0.4), (10.0, 8.0)); + ok(&mut d, con(ConstraintKind::Coincident, &[s(a, Sub::End), s(b, Sub::Start)])); + let (la, lb) = (ln(&d, a), ln(&d, b)); + assert!(la.1.near(lb.0, 1e-7)); + // The second object moved, the first stayed (minimum movement prefers equal split, but both + // ends ended up together). + let c = circle(&mut d, (5.0, 6.0), 2.0); + let p = add_e(&mut d, EntityKind::Point(DPoint { p: v(5.0, 9.0), angle: 0.0 })); + ok(&mut d, con(ConstraintKind::Coincident, &[w(p), w(c)])); + let (cc, r) = circ(&d, c); + let pp = match &d.entity(p).unwrap().kind { + EntityKind::Point(q) => q.p.xy(), + _ => panic!(), + }; + assert!(near(pp.dist(cc), r)); +} + +#[test] +fn horizontal_vertical_parallel_perpendicular() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.5)); + let b = line(&mut d, (0.0, 2.0), (0.3, 9.0)); + let c = line(&mut d, (5.0, 5.0), (9.0, 6.0)); + let e = line(&mut d, (5.0, -5.0), (6.0, -1.0)); + ok(&mut d, con(ConstraintKind::Horizontal, &[w(a)])); + ok(&mut d, con(ConstraintKind::Vertical, &[w(b)])); + ok(&mut d, con(ConstraintKind::Parallel, &[w(a), w(c)])); + ok(&mut d, con(ConstraintKind::Perpendicular, &[w(a), w(e)])); + let (a0, a1) = ln(&d, a); + assert!(near(a0.y, a1.y)); + let (b0, b1) = ln(&d, b); + assert!(near(b0.x, b1.x)); + let (c0, c1) = ln(&d, c); + assert!(near(c0.y, c1.y)); + let (e0, e1) = ln(&d, e); + assert!(near(e0.x, e1.x)); +} + +#[test] +fn collinear_concentric_equal() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); + let b = line(&mut d, (12.0, 0.5), (20.0, -0.3)); + ok(&mut d, con(ConstraintKind::Collinear, &[w(a), w(b)])); + let (a0, a1) = ln(&d, a); + let (b0, b1) = ln(&d, b); + let dir = (a1 - a0).normalized(); + assert!(dir.cross(b0 - a0).abs() < 1e-6 && dir.cross(b1 - a0).abs() < 1e-6); + + let c1 = circle(&mut d, (0.0, 20.0), 3.0); + let c2 = arc(&mut d, (0.5, 20.4), 5.0, 0.0, 2.0); + ok(&mut d, con(ConstraintKind::Concentric, &[w(c1), w(c2)])); + assert!(circ(&d, c1).0.near(circ(&d, c2).0, 1e-7)); + ok(&mut d, con(ConstraintKind::Equal, &[w(c1), w(c2)])); + assert!(near(circ(&d, c1).1, circ(&d, c2).1)); + ok(&mut d, con(ConstraintKind::Equal, &[w(a), w(b)])); + let (a0, a1) = ln(&d, a); + let (b0, b1) = ln(&d, b); + assert!(near(a0.dist(a1), b0.dist(b1))); +} + +#[test] +fn tangent_and_smooth() { + let mut d = Drawing::default(); + let l = line(&mut d, (-10.0, 0.0), (10.0, 0.0)); + let c = circle(&mut d, (0.0, 3.5), 3.0); + ok(&mut d, con(ConstraintKind::Tangent, &[w(l), w(c)])); + let (cc, r) = circ(&d, c); + let (a, b) = ln(&d, l); + assert!(near((b - a).normalized().cross(cc - a).abs(), r)); + // Circle–circle external. + let c2 = circle(&mut d, (8.0, 3.5), 4.0); + ok(&mut d, con(ConstraintKind::Tangent, &[w(c), w(c2)])); + let ((p, r1), (q, r2)) = (circ(&d, c), circ(&d, c2)); + assert!(near(p.dist(q), r1 + r2)); + // Smooth behaves as tangent. + let l2 = line(&mut d, (20.0, 0.0), (30.0, 1.0)); + let a2 = arc(&mut d, (25.0, 3.0), 2.5, 0.0, 3.0); + ok(&mut d, con(ConstraintKind::Smooth, &[w(l2), w(a2)])); + let ((ca, ra), (x0, x1)) = (circ(&d, a2), ln(&d, l2)); + assert!(near((x1 - x0).normalized().cross(ca - x0).abs(), ra)); +} + +#[test] +fn symmetric_fix() { + let mut d = Drawing::default(); + let axis = line(&mut d, (0.0, -10.0), (0.0, 10.0)); + ok(&mut d, con(ConstraintKind::Fix, &[w(axis)])); + let a = line(&mut d, (-5.0, 0.0), (-3.0, 4.0)); + let b = line(&mut d, (5.5, 0.3), (2.0, 4.5)); + ok(&mut d, con(ConstraintKind::Symmetric, &[w(a), w(b), w(axis)])); + assert_eq!(ln(&d, axis), (Vec2::new(0.0, -10.0), Vec2::new(0.0, 10.0))); + let (a0, a1) = ln(&d, a); + let (b0, b1) = ln(&d, b); + assert!(a0.near(Vec2::new(-b0.x, b0.y), 1e-6) && a1.near(Vec2::new(-b1.x, b1.y), 1e-6)); + // Symmetric points. + let p = add_e(&mut d, EntityKind::Point(DPoint { p: v(-2.0, 7.0), angle: 0.0 })); + let q = add_e(&mut d, EntityKind::Point(DPoint { p: v(2.5, 6.0), angle: 0.0 })); + ok(&mut d, con(ConstraintKind::Symmetric, &[w(p), w(q), w(axis)])); +} + +#[test] +fn dimensional_constraints() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 1.0)); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Aligned), &[w(a)], "", "12")); + let (a0, a1) = ln(&d, a); + assert!(near(a0.dist(a1), 12.0)); + let b = line(&mut d, (0.0, 5.0), (7.0, 9.0)); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Horizontal), &[w(b)], "", "4")); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Vertical), &[w(b)], "", "d1/4")); + let (b0, b1) = ln(&d, b); + assert!(near(b1.x - b0.x, 4.0) && near(b1.y - b0.y, 3.0)); + let c = line(&mut d, (20.0, 0.0), (30.0, 2.0)); + let e = line(&mut d, (20.0, 0.0), (25.0, 6.0)); + ok(&mut d, dim(ConstraintKind::Angular, &[w(c), w(e)], "", "45")); + let ((c0, c1), (e0, e1)) = (ln(&d, c), ln(&d, e)); + let (u, t) = ((c1 - c0).normalized(), (e1 - e0).normalized()); + assert!(near(u.cross(t).atan2(u.dot(t)).to_degrees(), 45.0)); + let k = circle(&mut d, (0.0, 30.0), 3.0); + ok(&mut d, dim(ConstraintKind::Radius, &[w(k)], "", "5")); + assert!(near(circ(&d, k).1, 5.0)); + let k2 = arc(&mut d, (20.0, 30.0), 3.0, 0.0, 1.0); + ok(&mut d, dim(ConstraintKind::Diameter, &[w(k2)], "", "rad1*2+2")); + assert!(near(circ(&d, k2).1, 6.0)); + let names: Vec = d.constraints.iter().map(|c| c.name.clone()).collect(); + assert_eq!(names, ["d1", "d2", "d3", "ang1", "rad1", "dia1"]); + // Measured default value. + let f = line(&mut d, (0.0, 50.0), (3.0, 54.0)); + let id = ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Aligned), &[w(f)], "", "")); + assert_eq!(d.constraints.iter().find(|c| c.id == id).unwrap().expr, "5"); + assert!(all_satisfied(&d)); +} + +/// A rectangle of four lines: coincident corners, horizontal/vertical sides, a fixed corner, +/// width and height parameters. Changing a parameter moves the geometry. +#[test] +fn rectangle_chain_with_parameters() { + let mut d = Drawing::default(); + let bottom = line(&mut d, (0.0, 0.0), (10.2, 0.3)); + let right = line(&mut d, (10.0, 0.1), (9.8, 5.0)); + let top = line(&mut d, (10.1, 5.2), (0.2, 4.9)); + let left = line(&mut d, (0.0, 5.0), (0.1, -0.2)); + for (p, q) in [(bottom, right), (right, top), (top, left), (left, bottom)] { + ok(&mut d, con(ConstraintKind::Coincident, &[s(p, Sub::End), s(q, Sub::Start)])); + } + ok(&mut d, con(ConstraintKind::Horizontal, &[w(bottom)])); + ok(&mut d, con(ConstraintKind::Horizontal, &[w(top)])); + ok(&mut d, con(ConstraintKind::Vertical, &[w(left)])); + ok(&mut d, con(ConstraintKind::Vertical, &[w(right)])); + ok(&mut d, con(ConstraintKind::Fix, &[s(bottom, Sub::Start)])); + set_parameter(&mut d, "width", "10", &SolveOptions::default()).unwrap(); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Horizontal), &[w(bottom)], "w", "width")); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Vertical), &[w(right)], "h", "w/2")); + // Equal opposite sides are implied, so adding one is redundant. + let e = add(&mut d, con(ConstraintKind::Equal, &[w(bottom), w(top)]), &SolveOptions::default()); + assert!(matches!(e, Err(SolveError::Redundant { .. })), "{e:?}"); + let corners = |d: &Drawing| [ln(d, bottom).0, ln(d, bottom).1, ln(d, top).0, ln(d, top).1]; + let c = corners(&d); + let o = c[0]; + assert!((c[1] - o).near(Vec2::new(10.0, 0.0), 1e-6), "{c:?}"); + assert!((c[2] - o).near(Vec2::new(10.0, 5.0), 1e-6), "{c:?}"); + assert!((c[3] - o).near(Vec2::new(0.0, 5.0), 1e-6), "{c:?}"); + set_parameter(&mut d, "width", "20", &SolveOptions::default()).unwrap(); + let c = corners(&d); + assert!(c[0].near(o, 1e-12), "fixed corner moved"); + assert!((c[2] - o).near(Vec2::new(20.0, 10.0), 1e-6), "{c:?}"); + assert!(all_satisfied(&d)); + // A bad expression is refused and leaves the drawing alone. + let before = d.clone(); + assert!(set_parameter(&mut d, "width", "w*2", &SolveOptions::default()).is_err()); + assert!(set_parameter(&mut d, "width", "1/0", &SolveOptions::default()).is_err()); + assert!(set_parameter(&mut d, "width", "(", &SolveOptions::default()).is_err()); + assert_eq!(d, before); + let table = parameter_table(&d); + assert_eq!(table.len(), 3); + assert!(table.iter().any(|r| r.name == "h" && r.value == Some(10.0))); +} + +#[test] +fn polyline_rectangle() { + let mut d = Drawing::default(); + let vs = [(0.0, 0.0), (8.0, 0.4), (8.3, 3.0), (-0.2, 3.1)].iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(); + let p = add_e(&mut d, EntityKind::LwPolyline(LwPolyline { vertices: vs, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false })); + for i in [0, 2] { + ok(&mut d, con(ConstraintKind::Horizontal, &[s(p, Sub::Segment(i))])); + } + for i in [1, 3] { + ok(&mut d, con(ConstraintKind::Vertical, &[s(p, Sub::Segment(i))])); + } + ok(&mut d, con(ConstraintKind::Fix, &[s(p, Sub::Vertex(0))])); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Aligned), &[s(p, Sub::Segment(0))], "", "6")); + ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Aligned), &[s(p, Sub::Segment(1))], "", "2")); + let EntityKind::LwPolyline(pl) = &d.entity(p).unwrap().kind else { panic!() }; + let pts: Vec = pl.vertices.iter().map(|v| v.p).collect(); + // Vertex 0 was fixed where the earlier constraints left it. + assert!((pts[2] - pts[0]).near(Vec2::new(6.0, 2.0), 1e-6), "{pts:?}"); +} + +#[test] +fn conflicts_are_reported_and_nothing_moves() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 1.0)); + let h = ok(&mut d, con(ConstraintKind::Horizontal, &[w(a)])); + let before = d.clone(); + // Horizontal and vertical on the same (non-degenerate) line, with a length: inconsistent. + let l = ok(&mut d, dim(ConstraintKind::Distance(DistAxis::Aligned), &[w(a)], "", "10")); + let before2 = d.clone(); + let e = add(&mut d, con(ConstraintKind::Vertical, &[w(a)]), &SolveOptions::default()); + match e { + Err(SolveError::Conflict { ids }) => { + assert_eq!(ids[0], 3); + assert!(ids.contains(&h) || ids.contains(&l), "{ids:?}"); + } + other => panic!("{other:?}"), + } + assert_eq!(d, before2); + // Redundant: horizontal twice. + assert!(matches!(add(&mut d, con(ConstraintKind::Horizontal, &[w(a)]), &SolveOptions::default()), Err(SolveError::Redundant { .. }))); + // Fixed line can't be made vertical. + let mut d2 = before.clone(); + let b = line(&mut d2, (0.0, 0.0), (3.0, 4.0)); + ok(&mut d2, con(ConstraintKind::Fix, &[w(b)])); + assert!(matches!(add(&mut d2, con(ConstraintKind::Vertical, &[w(b)]), &SolveOptions::default()), Err(SolveError::Conflict { .. }))); + // A direct solve of an inconsistent stored system fails without writing. + let mut d3 = before.clone(); + d3.constraints.push(Constraint { id: 9, kind: ConstraintKind::Vertical, refs: vec![w(a)], name: String::new(), expr: String::new() }); + d3.constraints.push(dim(ConstraintKind::Distance(DistAxis::Aligned), &[w(a)], "d9", "10")); + let snapshot = d3.clone(); + assert!(matches!(solve(&mut d3, &SolveOptions::default()), Err(SolveError::Conflict { .. }))); + assert_eq!(d3, snapshot); +} + +#[test] +fn keep_option_moves_the_other_entity() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); + let b = line(&mut d, (0.0, 2.0), (10.0, 4.0)); + add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap(); + let (a0, a1) = ln(&d, a); + assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}"); +} + +#[test] +fn invalid_references_and_purge() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); + let c = circle(&mut d, (0.0, 5.0), 1.0); + let t = add_e( + &mut d, + EntityKind::Text(cadcraft_doc::Text { + insert: v(0.0, 0.0), + align_pt: None, + height: 1.0, + value: "x".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }), + ); + let bad = |d: &mut Drawing, c: Constraint| assert!(add(d, c, &SolveOptions::default()).is_err()); + bad(&mut d, con(ConstraintKind::Horizontal, &[w(c)])); + bad(&mut d, con(ConstraintKind::Parallel, &[w(a)])); + bad(&mut d, con(ConstraintKind::Horizontal, &[w(t)])); + bad(&mut d, con(ConstraintKind::Horizontal, &[w(Handle(0xFFFF))])); + bad(&mut d, con(ConstraintKind::Coincident, &[s(a, Sub::Vertex(99)), s(a, Sub::Start)])); + bad(&mut d, con(ConstraintKind::Radius, &[w(a)])); + bad(&mut d, dim(ConstraintKind::Radius, &[w(c)], "", "-3")); + bad(&mut d, dim(ConstraintKind::Radius, &[w(c)], "1bad", "3")); + bad(&mut d, dim(ConstraintKind::Radius, &[w(c)], "", "nope*2")); + bad(&mut d, con(ConstraintKind::Symmetric, &[w(a), w(c)])); + bad(&mut d, con(ConstraintKind::Fix, &[])); + assert!(d.constraints.is_empty()); + ok(&mut d, con(ConstraintKind::Horizontal, &[w(a)])); + d.remove_entity(a); + assert_eq!(purge_invalid(&mut d), vec![1]); + assert!(d.constraints.is_empty()); +} + +#[test] +fn hostile_geometry_never_panics() { + let mut d = Drawing::default(); + let zero = line(&mut d, (1.0, 1.0), (1.0, 1.0)); + let huge = line(&mut d, (1e300, -1e300), (-1e300, 1e300)); + let nan = line(&mut d, (f64::NAN, 0.0), (1.0, f64::INFINITY)); + let c0 = circle(&mut d, (0.0, 0.0), 0.0); + let kinds = [ + ConstraintKind::Coincident, + ConstraintKind::Collinear, + ConstraintKind::Concentric, + ConstraintKind::Fix, + ConstraintKind::Parallel, + ConstraintKind::Perpendicular, + ConstraintKind::Horizontal, + ConstraintKind::Vertical, + ConstraintKind::Tangent, + ConstraintKind::Smooth, + ConstraintKind::Symmetric, + ConstraintKind::Equal, + ConstraintKind::Distance(DistAxis::Aligned), + ConstraintKind::Distance(DistAxis::Horizontal), + ConstraintKind::Angular, + ConstraintKind::Radius, + ConstraintKind::Diameter, + ]; + let hs = [zero, huge, nan, c0, Handle(0), Handle(u64::MAX)]; + let subs = [Sub::Whole, Sub::Start, Sub::Mid, Sub::Center, Sub::Vertex(u32::MAX), Sub::Segment(u32::MAX)]; + for k in kinds { + for (i, h) in hs.iter().enumerate() { + for sub in subs { + let refs = [s(*h, sub), w(hs[(i + 1) % hs.len()]), w(hs[(i + 2) % hs.len()])]; + for n in 0..=3 { + let _ = add(&mut d, dim(k, &refs[..n], "", "1e308"), &SolveOptions::default()); + let _ = add(&mut d, dim(k, &refs[..n], "", ""), &SolveOptions::default()); + } + } + } + } + let _ = solve(&mut d, &SolveOptions::default()); + let _ = glyphs(&d); + let _ = residuals(&d); + let _ = parameter_table(&d); + let _ = purge_invalid(&mut d); +} + +#[test] +fn glyphs_and_residuals() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); + ok(&mut d, con(ConstraintKind::Horizontal, &[w(a)])); + let g = glyphs(&d); + assert_eq!(g.len(), 1); + assert_eq!(g[0].kind, "Horizontal"); + assert!(g[0].satisfied); + assert!(g[0].anchors[0].near(Vec2::new(5.0, 0.0), 1e-12)); + d.modify_entity(a, |e| { + if let EntityKind::Line(l) = &mut e.kind { + l.b.y = 3.0; + } + }) + .unwrap(); + assert!(!all_satisfied(&d)); + let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap(); + assert_eq!(r.moved, vec![a]); + assert!(all_satisfied(&d)); +} + +#[test] +fn autoconstrain_rectangle_of_lines() { + let mut d = Drawing::default(); + let hs = vec![ + line(&mut d, (0.0, 0.0), (10.0, 0.01)), + line(&mut d, (10.01, 0.0), (10.0, 5.0)), + line(&mut d, (10.0, 5.0), (0.0, 5.02)), + line(&mut d, (0.0, 5.0), (0.0, 0.0)), + ]; + let ids = autoconstrain(&mut d, &hs, &InferOptions::default()); + let kinds: Vec<&str> = d.constraints.iter().map(|c| c.kind.name()).collect(); + assert_eq!(kinds.iter().filter(|k| **k == "Coincident").count(), 4, "{kinds:?}"); + assert_eq!(kinds.iter().filter(|k| **k == "Horizontal").count(), 2, "{kinds:?}"); + assert_eq!(kinds.iter().filter(|k| **k == "Vertical").count(), 2, "{kinds:?}"); + // Perpendicular/parallel/equal are implied and were skipped as redundant. + assert_eq!(ids.len(), 8, "{kinds:?}"); + assert!(all_satisfied(&d)); + let (a, b) = ln(&d, hs[0]); + assert!(near(a.y, b.y)); + // Circles: concentric and equal. + let c1 = circle(&mut d, (30.0, 0.0), 2.0); + let c2 = circle(&mut d, (30.01, 0.0), 2.01); + autoconstrain(&mut d, &[c1, c2], &InferOptions::default()); + assert!(circ(&d, c1).0.near(circ(&d, c2).0, 1e-7) && near(circ(&d, c1).1, circ(&d, c2).1)); + // Type filter. + let l = line(&mut d, (50.0, 0.0), (60.0, 0.001)); + let only_v = InferOptions { types: vec!["Vertical".into()], ..Default::default() }; + assert!(autoconstrain(&mut d, &[l], &only_v).is_empty()); +} diff --git a/crates/doc/src/constraint.rs b/crates/doc/src/constraint.rs new file mode 100644 index 00000000..b3cbb69a --- /dev/null +++ b/crates/doc/src/constraint.rs @@ -0,0 +1,227 @@ +//! Parametric drawing data: geometric and dimensional constraints, user parameters and the +//! parametric settings (GEOMCONSTRAINT, DIMCONSTRAINT, PARAMETERS, CONSTRAINTSETTINGS). +//! +//! Only the data lives here; the solver is `cadcraft-constraints`. + +use serde::{Deserialize, Serialize}; + +use crate::Handle; + +/// Which part of an entity a constraint refers to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "index")] +pub enum Sub { + /// The object itself (a line, circle, arc, point). + #[default] + Whole, + Start, + End, + Mid, + Center, + /// Polyline vertex `i`. + Vertex(u32), + /// Polyline segment `i` (from vertex `i` to the next one), treated as a line. + Segment(u32), +} + +impl Sub { + /// Parse `start`, `end`, `mid`, `center`, `whole`, `v3` / `vertex3`, `s2` / `seg2` / `segment2`. + pub fn parse(t: &str) -> Option { + let l = t.trim().to_ascii_lowercase(); + Some(match l.as_str() { + "" | "whole" | "object" | "o" => Sub::Whole, + "start" | "s" => Sub::Start, + "end" | "e" => Sub::End, + "mid" | "midpoint" | "m" => Sub::Mid, + "center" | "centre" | "c" => Sub::Center, + _ => { + for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("v", true)] { + if let Some(n) = l.strip_prefix(p) { + let i: u32 = n.trim().parse().ok()?; + return Some(if vertex { Sub::Vertex(i) } else { Sub::Segment(i) }); + } + } + return None; + } + }) + } + pub fn label(self) -> String { + match self { + Sub::Whole => "whole".into(), + Sub::Start => "start".into(), + Sub::End => "end".into(), + Sub::Mid => "mid".into(), + Sub::Center => "center".into(), + Sub::Vertex(i) => format!("v{i}"), + Sub::Segment(i) => format!("seg{i}"), + } + } +} + +/// A reference to (part of) an entity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GeomRef { + pub handle: Handle, + #[serde(default)] + pub sub: Sub, +} + +impl GeomRef { + pub fn new(handle: Handle, sub: Sub) -> Self { + GeomRef { handle, sub } + } + pub fn whole(handle: Handle) -> Self { + GeomRef { handle, sub: Sub::Whole } + } +} + +/// Axis of a linear dimensional constraint. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DistAxis { + #[default] + Aligned, + Horizontal, + Vertical, +} + +/// Constraint types. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ConstraintKind { + // geometric + Coincident, + Collinear, + Concentric, + Fix, + Parallel, + Perpendicular, + Horizontal, + Vertical, + Tangent, + /// G2 continuity; solved as tangency for now. + Smooth, + Symmetric, + Equal, + // dimensional + Distance(DistAxis), + /// Degrees. + Angular, + Radius, + Diameter, +} + +impl ConstraintKind { + pub fn is_dimensional(self) -> bool { + matches!(self, ConstraintKind::Distance(_) | ConstraintKind::Angular | ConstraintKind::Radius | ConstraintKind::Diameter) + } + /// The AutoCAD-style name (`Coincident`, `Horizontal`, `Aligned`, …). + pub fn name(self) -> &'static str { + match self { + ConstraintKind::Coincident => "Coincident", + ConstraintKind::Collinear => "Collinear", + ConstraintKind::Concentric => "Concentric", + ConstraintKind::Fix => "Fix", + ConstraintKind::Parallel => "Parallel", + ConstraintKind::Perpendicular => "Perpendicular", + ConstraintKind::Horizontal => "Horizontal", + ConstraintKind::Vertical => "Vertical", + ConstraintKind::Tangent => "Tangent", + ConstraintKind::Smooth => "Smooth", + ConstraintKind::Symmetric => "Symmetric", + ConstraintKind::Equal => "Equal", + ConstraintKind::Distance(DistAxis::Aligned) => "Aligned", + ConstraintKind::Distance(DistAxis::Horizontal) => "HorizontalDistance", + ConstraintKind::Distance(DistAxis::Vertical) => "VerticalDistance", + ConstraintKind::Angular => "Angular", + ConstraintKind::Radius => "Radius", + ConstraintKind::Diameter => "Diameter", + } + } + /// Default parameter name prefix for dimensional constraints (`d`, `ang`, `rad`, `dia`). + pub fn name_prefix(self) -> &'static str { + match self { + ConstraintKind::Angular => "ang", + ConstraintKind::Radius => "rad", + ConstraintKind::Diameter => "dia", + _ => "d", + } + } +} + +/// A constraint between entity parts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Constraint { + /// Unique per drawing. + pub id: u32, + pub kind: ConstraintKind, + pub refs: Vec, + /// Dimensional constraints: the parameter name (`d1`, `rad1`, …). + #[serde(default)] + pub name: String, + /// Dimensional constraints: the expression (`10`, `d1*2`, `width/2+1`). Lengths are in drawing + /// units, angles in degrees. + #[serde(default)] + pub expr: String, +} + +/// A user parameter (Parameters Manager). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Parameter { + pub name: String, + pub expr: String, + #[serde(default)] + pub description: String, +} + +/// Constraint inference and display options (CONSTRAINTSETTINGS, CONSTRAINTBAR, DCDISPLAY). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ParametricSettings { + /// CONSTRAINTINFER: infer constraints while drawing. + pub infer: bool, + /// AUTOCONSTRAIN distance tolerance (drawing units). + pub distance_tolerance: f64, + /// AUTOCONSTRAIN angle tolerance (degrees). + pub angle_tolerance: f64, + /// Constraint types AUTOCONSTRAIN may apply (names as in [`ConstraintKind::name`]). + pub auto_types: Vec, + /// Constraint bars shown for all objects. + pub bars_visible: bool, + /// Objects whose bars are individually hidden (when `bars_visible`) or shown (otherwise). + pub bar_exceptions: Vec, + /// Dynamic dimensional constraints shown. + pub dims_visible: bool, + pub dim_exceptions: Vec, + /// Constraint bar transparency, percent. + pub bar_transparency: u8, +} + +impl Default for ParametricSettings { + fn default() -> Self { + ParametricSettings { + infer: false, + distance_tolerance: 0.05, + angle_tolerance: 1.0, + auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"] + .iter() + .map(|s| s.to_string()) + .collect(), + bars_visible: true, + bar_exceptions: Vec::new(), + dims_visible: true, + dim_exceptions: Vec::new(), + bar_transparency: 50, + } + } +} + +/// User parameters plus parametric settings, stored on the drawing. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Parametric { + pub parameters: Vec, + pub settings: ParametricSettings, +} diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index fe4c8336..6d9af0c7 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -329,6 +329,49 @@ pub struct Dimension { /// Anonymous block with the rendered geometry (from files); regenerated when edited. #[serde(default)] pub block: Option, + /// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values, + /// merged over the named style. + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub overrides: serde_json::Map, + /// Associativity: which definition points follow which objects. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assoc: Vec, +} + +/// A dimension definition point that follows an object (associative dimensions). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DimAssoc { + /// Definition point: "defpt", "p13", "p14", "p15" or "p16". + pub point: String, + /// The object it is attached to. + pub handle: Handle, + pub snap: AssocSnap, +} + +/// Where on the object an associative definition point sits. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AssocSnap { + /// Start point of a line/arc (or polyline vertex 0). + Start, + /// End point of a line/arc. + End, + Mid, + /// Centre of a circle/arc. + Center, + /// On a circle/arc at a fixed angle (radians) from its centre. + OnCircle { + angle: f64, + }, + /// Intersection of this (line) object with another line. + Intersection { + other: Handle, + }, + /// Vertex `index` of a polyline. + Vertex { + index: usize, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -461,6 +504,12 @@ pub struct Table { pub style: String, #[serde(default = "default_text_h")] pub text_height: f64, + /// First row is a title (merged across, larger text). + #[serde(default)] + pub title: bool, + /// The row after the title holds column headers. + #[serde(default)] + pub header: bool, } fn default_text_h() -> f64 { 0.18 diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c458e8b7..d656a582 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod constraint; mod entity; mod extents; mod header; @@ -17,6 +18,7 @@ use std::collections::BTreeMap; pub use cadcraft_color as color; pub use cadcraft_geom as geom; +pub use constraint::*; pub use entity::*; pub use extents::{MAX_BLOCK_DEPTH, entity_bounds}; pub use header::{HVal, Header}; @@ -62,6 +64,10 @@ pub struct Drawing { pub ucss: Vec, pub layer_states: Vec, pub groups: Vec, + /// Parametric constraints (GEOMCONSTRAINT / DIMCONSTRAINT). + pub constraints: Vec, + /// User parameters and parametric settings. + pub parametric: Parametric, /// Next free handle. pub handseed: u64, } @@ -107,6 +113,8 @@ impl Drawing { ucss: Vec::new(), layer_states: Vec::new(), groups: Vec::new(), + constraints: Vec::new(), + parametric: Parametric::default(), handseed: 0x100, } } diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index e3c8be2b..f6cbcf26 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -185,8 +185,84 @@ pub struct DimStyle { /// DIMDLI baseline spacing. pub baseline_spacing: f64, pub annotative: bool, + /// DIMBLK1 / DIMBLK2: per-end arrowheads ("" = use `arrow_block`). + pub arrow_block1: String, + pub arrow_block2: String, + /// DIMDLE dimension line extension past the extension lines (ticks / oblique arrows). + pub dim_line_extend: f64, + /// DIMJUST: 0 centred, 1 next to ext line 1, 2 next to ext line 2, 3 over ext line 1, 4 over ext line 2. + pub text_just: u8, + /// DIMRND rounding increment (0 = none). + pub round: f64, + /// DIMDSEP decimal separator. + pub decimal_separator: String, + /// DIMFRAC: 0 horizontal stack, 1 diagonal stack, 2 not stacked. + pub fraction_format: u8, + /// DIMLIM: show limits (upper over lower) instead of the measurement. + pub limits: bool, + /// DIMTDEC tolerance decimals. + pub tol_decimals: u8, + /// DIMTFAC tolerance text height relative to the dimension text. + pub tol_scale: f64, + /// DIMALT alternate units, DIMALTF factor, DIMALTD decimals, DIMAPOST template. + pub alt: bool, + pub alt_factor: f64, + pub alt_decimals: u8, + pub alt_post: String, + /// DIMAUNIT: 0 decimal degrees, 1 deg/min/sec, 2 grads, 3 radians. + pub angular_unit: u8, + /// DIMSE1 / DIMSE2 suppress extension lines. + pub suppress_ext1: bool, + pub suppress_ext2: bool, } +/// DIMSTYLE system variable names and the [`DimStyle`] fields they set. +pub const DIMVARS: &[(&str, &str)] = &[ + ("DIMSCALE", "scale"), + ("DIMASZ", "arrowSize"), + ("DIMEXO", "extOffset"), + ("DIMEXE", "extExtend"), + ("DIMTXT", "textHeight"), + ("DIMGAP", "textGap"), + ("DIMDEC", "decimals"), + ("DIMADEC", "angularDecimals"), + ("DIMLFAC", "linearFactor"), + ("DIMTAD", "textAbove"), + ("DIMTIH", "textInsideHorizontal"), + ("DIMTOH", "textOutsideHorizontal"), + ("DIMBLK", "arrowBlock"), + ("DIMBLK1", "arrowBlock1"), + ("DIMBLK2", "arrowBlock2"), + ("DIMTSZ", "tickSize"), + ("DIMCLRD", "dimLineColor"), + ("DIMCLRE", "extLineColor"), + ("DIMCLRT", "textColor"), + ("DIMTXSTY", "textStyle"), + ("DIMPOST", "post"), + ("DIMCEN", "centerMark"), + ("DIMZIN", "zeroSuppression"), + ("DIMLUNIT", "linearUnit"), + ("DIMTOL", "tolerance"), + ("DIMTP", "tolPlus"), + ("DIMTM", "tolMinus"), + ("DIMDLI", "baselineSpacing"), + ("DIMDLE", "dimLineExtend"), + ("DIMJUST", "textJust"), + ("DIMRND", "round"), + ("DIMDSEP", "decimalSeparator"), + ("DIMFRAC", "fractionFormat"), + ("DIMLIM", "limits"), + ("DIMTDEC", "tolDecimals"), + ("DIMTFAC", "tolScale"), + ("DIMALT", "alt"), + ("DIMALTF", "altFactor"), + ("DIMALTD", "altDecimals"), + ("DIMAPOST", "altPost"), + ("DIMAUNIT", "angularUnit"), + ("DIMSE1", "suppressExt1"), + ("DIMSE2", "suppressExt2"), +]; + impl Default for DimStyle { fn default() -> Self { DimStyle { @@ -218,11 +294,100 @@ impl Default for DimStyle { tol_minus: 0.0, baseline_spacing: 0.38, annotative: false, + arrow_block1: String::new(), + arrow_block2: String::new(), + dim_line_extend: 0.0, + text_just: 0, + round: 0.0, + decimal_separator: ".".into(), + fraction_format: 0, + limits: false, + tol_decimals: 4, + tol_scale: 1.0, + alt: false, + alt_factor: 25.4, + alt_decimals: 2, + alt_post: String::new(), + angular_unit: 0, + suppress_ext1: false, + suppress_ext2: false, } } } impl DimStyle { + /// The field a name refers to: a camelCase field name or a DIM* variable name. + pub fn field_name(name: &str) -> Option<&'static str> { + DIMVARS + .iter() + .find(|(var, field)| var.eq_ignore_ascii_case(name) || field.eq_ignore_ascii_case(name)) + .map(|(_, f)| *f) + .or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name))) + } + + /// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as + /// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or + /// had unusable values. + pub fn apply_fields(&mut self, src: &serde_json::Map) -> Vec { + use serde_json::Value; + let mut rejected = Vec::new(); + let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() }; + for (k, v) in src { + let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else { + rejected.push(k.clone()); + continue; + }; + let Some(obj) = cur.as_object_mut() else { break }; + let Some(old) = obj.get(field).cloned() else { + rejected.push(k.clone()); + continue; + }; + let coerced = match (&old, v) { + (Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)), + (Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")), + (Value::Number(_), Value::String(t)) => match t.trim().parse::() { + Ok(x) => serde_json::json!(x), + Err(_) => v.clone(), + }, + (Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)), + (Value::String(_), Value::Number(n)) => Value::String(n.to_string()), + (Value::Object(_), Value::Number(n)) => { + let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci); + c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()) + } + (Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()), + _ => v.clone(), + }; + // Integers stored in u8 fields: round floats. + let coerced = match (&old, &coerced) { + (Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => { + serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64) + } + _ => coerced, + }; + obj.insert(field.to_string(), coerced); + match serde_json::from_value::(cur.clone()) { + Ok(ns) => *self = ns, + Err(_) => { + rejected.push(k.clone()); + if let Some(o) = cur.as_object_mut() { + o.insert(field.to_string(), old); + } + } + } + } + rejected + } + + /// This style with a dimension's overrides applied. + pub fn with_overrides(&self, o: &serde_json::Map) -> DimStyle { + let mut st = self.clone(); + if !o.is_empty() { + st.apply_fields(o); + } + st + } + /// The ISO-25 style for metric drawings. pub fn iso25() -> Self { DimStyle { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c68b7955..6e6921df 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -12,6 +12,7 @@ workspace = true cadcraft-geom = { workspace = true } cadcraft-color = { workspace = true } cadcraft-doc = { workspace = true } +cadcraft-constraints = { workspace = true } cadcraft-fonts = { workspace = true } cadcraft-render = { workspace = true } serde = { workspace = true } diff --git a/crates/engine/src/assoc.rs b/crates/engine/src/assoc.rs new file mode 100644 index 00000000..b2ee9688 --- /dev/null +++ b/crates/engine/src/assoc.rs @@ -0,0 +1,294 @@ +//! Associative dimensions: definition points attached to objects (`Dimension::assoc`) follow +//! them. [`update`] runs after every command that changed the drawing (from +//! `Session::execute` and when an interactive command finishes). + +use std::sync::Arc; + +use cadcraft_doc::{AssocSnap, DimAssoc, DimKind, Dimension, Drawing, EntityKind, Handle, Space}; +use cadcraft_geom::{Vec2, Vec3}; + +/// Definition point names in the order they are tried. +const POINTS: [&str; 5] = ["defpt", "p13", "p14", "p15", "p16"]; + +/// A definition point of a dimension by name. +pub fn def_point(d: &Dimension, name: &str) -> Option { + Some(match name { + "defpt" => d.defpt, + "p13" => d.p13, + "p14" => d.p14, + "p15" => d.p15, + "p16" => d.p16, + "text" => d.text_mid, + _ => return None, + }) +} + +fn def_point_mut<'a>(d: &'a mut Dimension, name: &str) -> Option<&'a mut Vec3> { + Some(match name { + "defpt" => &mut d.defpt, + "p13" => &mut d.p13, + "p14" => &mut d.p14, + "p15" => &mut d.p15, + "p16" => &mut d.p16, + _ => return None, + }) +} + +fn arc_geom(a: &cadcraft_doc::Arc) -> cadcraft_geom::Arc { + cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end) +} + +/// Where a snap sits on an object now. +pub fn snap_point(d: &Drawing, a: &DimAssoc) -> Option { + let e = d.entity(a.handle)?; + match (&e.kind, &a.snap) { + (EntityKind::Line(l), AssocSnap::Start) => Some(l.a.xy()), + (EntityKind::Line(l), AssocSnap::End) => Some(l.b.xy()), + (EntityKind::Line(l), AssocSnap::Mid) => Some(l.a.xy().mid(l.b.xy())), + (EntityKind::Line(l), AssocSnap::Intersection { other }) => match &d.entity(*other)?.kind { + EntityKind::Line(m) => cadcraft_geom::line_line_infinite(l.a.xy(), l.b.xy(), m.a.xy(), m.b.xy()).map(|x| x.0), + _ => None, + }, + (EntityKind::Arc(ar), AssocSnap::Start) => Some(arc_geom(ar).start_point()), + (EntityKind::Arc(ar), AssocSnap::End) => Some(arc_geom(ar).end_point()), + (EntityKind::Arc(ar), AssocSnap::Mid) => Some(arc_geom(ar).mid_point()), + (EntityKind::Arc(ar), AssocSnap::Center) => Some(ar.center.xy()), + (EntityKind::Arc(ar), AssocSnap::OnCircle { angle }) => Some(ar.center.xy() + Vec2::from_angle(*angle) * ar.radius), + (EntityKind::Circle(c), AssocSnap::Center) => Some(c.center.xy()), + (EntityKind::Circle(c), AssocSnap::OnCircle { angle }) => Some(c.center.xy() + Vec2::from_angle(*angle) * c.radius), + (EntityKind::LwPolyline(p), AssocSnap::Vertex { index }) => p.vertices.get(*index).map(|v| v.p), + (EntityKind::LwPolyline(p), AssocSnap::Start) => p.vertices.first().map(|v| v.p), + (EntityKind::LwPolyline(p), AssocSnap::End) => p.vertices.last().map(|v| v.p), + (EntityKind::Point(p), AssocSnap::Start | AssocSnap::Center) => Some(p.p.xy()), + _ => None, + } +} + +fn close(a: Vec2, b: Vec2) -> bool { + a.dist(b) <= 1e-6 * (1.0 + a.x.abs().max(a.y.abs())) +} + +/// Candidate snaps of an object (endpoints first, then centres, then midpoints). +fn candidates(kind: &EntityKind) -> Vec<(AssocSnap, Vec2)> { + match kind { + EntityKind::Line(l) => vec![(AssocSnap::Start, l.a.xy()), (AssocSnap::End, l.b.xy()), (AssocSnap::Mid, l.a.xy().mid(l.b.xy()))], + EntityKind::Arc(a) => { + let g = arc_geom(a); + vec![ + (AssocSnap::Start, g.start_point()), + (AssocSnap::End, g.end_point()), + (AssocSnap::Center, a.center.xy()), + (AssocSnap::Mid, g.mid_point()), + ] + } + EntityKind::Circle(c) => vec![(AssocSnap::Center, c.center.xy())], + EntityKind::LwPolyline(p) => p.vertices.iter().take(10_000).enumerate().map(|(i, v)| (AssocSnap::Vertex { index: i }, v.p)).collect(), + EntityKind::Point(p) => vec![(AssocSnap::Start, p.p.xy())], + _ => Vec::new(), + } +} + +/// An object point exactly at `p` (an endpoint, vertex, centre or midpoint) to attach to. +pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSnap)> { + if !p.is_finite() { + return None; + } + let store = d.space(space)?; + let mut best: Option<(usize, Handle, AssocSnap)> = None; + for e in store.iter() { + for (rank, (snap, q)) in candidates(&e.kind).into_iter().enumerate() { + let rank = match snap { + AssocSnap::Mid => 100 + rank, + AssocSnap::Center => 50 + rank, + _ => rank.min(1), + }; + if close(p, q) && best.as_ref().is_none_or(|b| rank < b.0) { + best = Some((rank, e.handle, snap)); + } + } + } + best.map(|(_, h, s)| (h, s)) +} + +/// Associations for named definition points found at their positions. +pub fn auto_assoc(d: &Drawing, space: &Space, pts: &[(&str, Vec2)]) -> Vec { + pts.iter().filter_map(|(name, p)| find_snap(d, space, *p).map(|(handle, snap)| DimAssoc { point: (*name).into(), handle, snap })).collect() +} + +/// A circle or arc (centre, radius) by handle. +fn circle_of(d: &Drawing, h: Handle) -> Option<(Vec2, f64)> { + match &d.entity(h)?.kind { + EntityKind::Circle(c) => Some((c.center.xy(), c.radius)), + EntityKind::Arc(a) => Some((a.center.xy(), a.radius)), + _ => None, + } +} + +/// A circle or arc whose centre is at `c` (and radius `r`, when given). +fn circle_at(d: &Drawing, space: &Space, c: Vec2, r: Option) -> Option { + d.space(space)?.iter().find_map(|e| { + let (cc, rr) = match &e.kind { + EntityKind::Circle(x) => (x.center.xy(), x.radius), + EntityKind::Arc(x) => (x.center.xy(), x.radius), + _ => return None, + }; + (close(cc, c) && r.is_none_or(|r| (r - rr).abs() <= 1e-6 * (1.0 + r.abs()))).then_some(e.handle) + }) +} + +/// Work out the associations of a dimension from the geometry under its definition points +/// (DIMREASSOCIATE). +pub fn reassociate(d: &Drawing, space: &Space, dm: &Dimension) -> Vec { + match dm.kind { + DimKind::Linear { .. } | DimKind::Aligned => auto_assoc(d, space, &[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]), + DimKind::Radius => { + let (c, p) = (dm.defpt.xy(), dm.p15.xy()); + match circle_at(d, space, c, Some(c.dist(p))) { + Some(h) => vec![ + DimAssoc { point: "defpt".into(), handle: h, snap: AssocSnap::Center }, + DimAssoc { point: "p15".into(), handle: h, snap: AssocSnap::OnCircle { angle: c.angle_to(p) } }, + ], + None => Vec::new(), + } + } + DimKind::Diameter => { + let (a, b) = (dm.defpt.xy(), dm.p15.xy()); + let c = a.mid(b); + match circle_at(d, space, c, Some(a.dist(b) / 2.0)) { + Some(h) => vec![ + DimAssoc { point: "defpt".into(), handle: h, snap: AssocSnap::OnCircle { angle: c.angle_to(a) } }, + DimAssoc { point: "p15".into(), handle: h, snap: AssocSnap::OnCircle { angle: c.angle_to(b) } }, + ], + None => Vec::new(), + } + } + DimKind::Angular3P | DimKind::ArcLength => auto_assoc(d, space, &[("p13", dm.p13.xy()), ("p14", dm.p14.xy()), ("p15", dm.p15.xy())]), + DimKind::Angular => auto_assoc(d, space, &[("p13", dm.p13.xy()), ("p14", dm.p14.xy()), ("defpt", dm.defpt.xy()), ("p15", dm.p15.xy())]), + DimKind::Ordinate { .. } => auto_assoc(d, space, &[("p13", dm.p13.xy())]), + } +} + +/// Associations for a radius/diameter dimension made by picking a circle or arc. +pub fn radial_assoc(d: &Drawing, h: Handle, diameter: bool, defpt: Vec2, p15: Vec2) -> Vec { + let Some((c, _)) = circle_of(d, h) else { return Vec::new() }; + let on = |name: &str, p: Vec2| DimAssoc { point: name.into(), handle: h, snap: AssocSnap::OnCircle { angle: c.angle_to(p) } }; + if diameter { + vec![on("defpt", defpt), on("p15", p15)] + } else { + vec![DimAssoc { point: "defpt".into(), handle: h, snap: AssocSnap::Center }, on("p15", p15)] + } +} + +fn src_changed(before: &Drawing, after: &Drawing, h: Handle) -> bool { + match (before.entity(h), after.entity(h)) { + (Some(x), Some(y)) => !Arc::ptr_eq(x, y) && x.kind != y.kind, + (Some(_), None) => true, + _ => false, + } +} + +/// Recompute an associative dimension's definition points from its objects. Attachments that +/// no longer hold (the object was erased, or the point had been moved off the object before +/// this change) are dropped. +/// `prev` is the dimension as it was before the change (when it existed). +pub fn recompute(dm: &Dimension, prev: Option<&Dimension>, before: &Drawing, after: &Drawing) -> Dimension { + let mut nd = dm.clone(); + let mut deltas: Vec = Vec::new(); + let mut moved: Vec = Vec::new(); + let was = prev.unwrap_or(dm); + nd.assoc.retain(|a| { + let Some(old) = def_point(was, &a.point) else { return false }; + // Only follow if the point sat on the object before the change. + if let Some(prev) = snap_point(before, a) + && !close(prev, old.xy()) + { + return false; + } + snap_point(after, a).is_some() + }); + for a in &nd.assoc { + if let (Some(p), Some(old)) = (snap_point(after, a), def_point(dm, &a.point)) { + deltas.push(p - old.xy()); + moved.push(a.point.clone()); + } + } + for a in nd.assoc.clone() { + if let (Some(p), Some(slot)) = (snap_point(after, &a), def_point_mut(&mut nd, &a.point)) { + slot.x = p.x; + slot.y = p.y; + } + } + // A rigid move of every attached point carries the rest of the dimension along. + if let Some(first) = deltas.first().copied() + && deltas.iter().all(|d| d.dist(first) <= 1e-9 * (1.0 + first.len())) + && first.len() > 0.0 + { + for name in POINTS { + if !moved.iter().any(|m| m == name) + && let Some(slot) = def_point_mut(&mut nd, name) + { + slot.x += first.x; + slot.y += first.y; + } + } + nd.text_mid.x += first.x; + nd.text_mid.y += first.y; + } + if nd != *dm { + nd.block = None; + } + nd +} + +/// After a change: update every associative dimension whose objects changed. +/// Returns the number of dimensions updated. +pub fn update(after: &mut Arc, before: &Drawing) -> usize { + let mut changes: Vec<(Handle, Dimension)> = Vec::new(); + { + let d: &Drawing = after; + let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)); + for store in stores { + for e in store.iter() { + let EntityKind::Dimension(dm) = &e.kind else { continue }; + if dm.assoc.is_empty() { + continue; + } + let touched = dm.assoc.iter().any(|a| { + src_changed(before, d, a.handle) || matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other)) + }); + if !touched { + continue; + } + let prev = match before.entity(e.handle).map(|x| &x.kind) { + Some(EntityKind::Dimension(p)) => Some(p), + _ => None, + }; + let nd = recompute(dm, prev, before, d); + if nd != *dm { + changes.push((e.handle, nd)); + } + } + } + } + if changes.is_empty() { + return 0; + } + let d = Arc::make_mut(after); + let n = changes.len(); + for (h, nd) in changes { + let _ = d.modify_entity(h, |e| e.kind = EntityKind::Dimension(nd)); + } + n +} + +/// The post-command pass: called by `Session::execute` and when an interactive command +/// finishes. `uid` (when known) is the document the command started in. +pub fn after_command(s: &mut crate::Session, before: Option<&Arc>, uid: Option) { + let Some(before) = before else { return }; + if let Ok(st) = s.state_mut() + && uid.is_none_or(|u| u == st.uid) + && !Arc::ptr_eq(before, &st.doc) + { + update(&mut st.doc, before); + } +} diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 20ae9d24..845a7015 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -1,7 +1,8 @@ //! Dimensions and leaders: DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMARC, -//! DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM, DIM (smart), MLEADER, LEADER, TOLERANCE. +//! DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM, DIM (smart), MLEADER, LEADER, DIMOVERRIDE, +//! DIMREASSOCIATE / DIMDISASSOCIATE, DIMTEDIT, DIMSPACE. -use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle, MLeader, MText, Prim}; +use cadcraft_doc::{AssocSnap, DimAssoc, DimKind, Dimension, EntityKind, Handle, MLeader, MText, Prim}; use cadcraft_geom::{Segment, Vec2, Vec3}; use serde_json::{Value, json}; @@ -14,7 +15,7 @@ pub fn specs() -> Vec { CommandSpec::new("dimlinear", "Linear", run_linear) .menu(&["Dimension", "Linear"]) .alias(&["dli", "dimlin"]) - .params("{p1, p2, at, rotation? (degrees; default: horizontal/vertical from `at`), text?}") + .params("{p1, p2, at, rotation? (degrees; default: horizontal/vertical from `at`), text?} | {object: hex, at} (associative)") .interactive(|_| Ok(Box::new(LinearM::new(false)))), CommandSpec::new("dimaligned", "Aligned", run_aligned) .menu(&["Dimension", "Aligned"]) @@ -34,7 +35,7 @@ pub fn specs() -> Vec { CommandSpec::new("dimangular", "Angular", run_angular) .menu(&["Dimension", "Angular"]) .alias(&["dan", "dimang"]) - .params("{vertex, p1, p2, at}") + .params("{vertex, p1, p2, at} | {lines: [hex, hex], at} | {arc: hex, at} (associative)") .interactive(|_| Ok(Box::new(AngularM::default()))), CommandSpec::new("dimarc", "Arc Length", run_arclen) .menu(&["Dimension", "Arc Length"]) @@ -70,7 +71,34 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(MLeaderM::default()))), CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"), CommandSpec::new("dimstyle.update", "Update", run_update).menu(&["Dimension", "Update"]).params("{handles?}"), - CommandSpec::new("dimoverride", "Override", run_override).menu(&["Dimension", "Override"]).params("{handles?, text}"), + CommandSpec::new("dimoverride", "Override", run_override) + .menu(&["Dimension", "Override"]) + .alias(&["dov", "dimover"]) + .params("{handles?, , clear?: bool, text?: override text}") + .interactive(|s| Ok(Box::new(OverrideM::new(s)))), + CommandSpec::new("dimreassociate", "Reassociate Dimensions", run_reassociate) + .menu(&["Dimension", "Reassociate Dimensions"]) + .alias(&["dre"]) + .params("{handles?} (dimensions; attaches their definition points to the objects under them)") + .interactive(|s| Ok(Box::new(SelectThen::new(s, "dimreassociate")))), + CommandSpec::new("dimdisassociate", "Disassociate Dimensions", run_disassociate) + .alias(&["dda"]) + .params("{handles?}") + .interactive(|s| Ok(Box::new(SelectThen::new(s, "dimdisassociate")))), + CommandSpec::new("dimtedit.home", "Home", |s, p| run_tedit(s, p, "home")).menu(&["Dimension", "Align Text", "Home"]).params("{handles?}"), + CommandSpec::new("dimtedit.angle", "Angle", |s, p| run_tedit(s, p, "angle")) + .menu(&["Dimension", "Align Text", "Angle"]) + .params("{handles?, angle: degrees}"), + CommandSpec::new("dimtedit.left", "Left", |s, p| run_tedit(s, p, "left")).menu(&["Dimension", "Align Text", "Left"]).params("{handles?}"), + CommandSpec::new("dimtedit.center", "Center", |s, p| run_tedit(s, p, "center")) + .menu(&["Dimension", "Align Text", "Center"]) + .params("{handles?}"), + CommandSpec::new("dimtedit.right", "Right", |s, p| run_tedit(s, p, "right")).menu(&["Dimension", "Align Text", "Right"]).params("{handles?}"), + CommandSpec::new("dimtedit", "Dimension Text Edit", run_dimtedit) + .params("{handles?, at?: [x,y], mode?: home|angle|left|center|right, angle?}"), + CommandSpec::new("dimspace", "Dimension Space", run_dimspace) + .menu(&["Dimension", "Dimension Space"]) + .params("{base: hex, handles: [hex], spacing?: number (default DIMDLI)}"), ] } @@ -93,6 +121,8 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text_rotation: 0.0, user_text_pos: false, block: None, + overrides: Default::default(), + assoc: Vec::new(), }) } @@ -109,15 +139,70 @@ fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option, aligned: boo if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } } } +/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints, +/// vertices or centres are attached to them (associative dimensions). fn add_dim(s: &mut Session, k: EntityKind) -> Result { + let mut k = k; + if let EntityKind::Dimension(dm) = &mut k + && dm.assoc.is_empty() + && assoc_enabled(s) + { + dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm); + } let h = s.add_entity(k)?; s.last_dim = Some(h); Ok(h) } +fn assoc_enabled(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false) +} + +fn with_assoc(k: EntityKind, assoc: Vec) -> EntityKind { + match k { + EntityKind::Dimension(mut d) => { + d.assoc = assoc; + EntityKind::Dimension(d) + } + other => other, + } +} + +/// Angular dimension between two lines, attached to them (vertex = their intersection). +fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option { + let d = s.doc().ok()?; + let line = |h: Handle| match &d.entity(h)?.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }; + let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?); + let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; + // Use the leg ends on the side of the picked arc location (the quadrant the user meant). + let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| { + let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) }; + let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) }; + let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized()); + if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir } + }; + let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End); + let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End); + let k = dim(s, DimKind::Angular3P, at, p1, p2, v, Vec2::ZERO, ""); + let mut assoc = Vec::new(); + if assoc_enabled(s) { + assoc = vec![ + DimAssoc { point: "p13".into(), handle: h1, snap: s1 }, + DimAssoc { point: "p14".into(), handle: h2, snap: s2 }, + DimAssoc { point: "p15".into(), handle: h1, snap: AssocSnap::Intersection { other: h2 } }, + ]; + } + Some(with_assoc(k, assoc)) +} + fn run_linear(s: &mut Session, p: &Value) -> Result { - let a = point_req("dimlinear", p, "p1")?; - let b = point_req("dimlinear", p, "p2")?; + let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) { + Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?, + None => (point_req("dimlinear", p, "p1")?, point_req("dimlinear", p, "p2")?), + }; let at = point_req("dimlinear", p, "at")?; let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians); let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or("")); @@ -166,7 +251,28 @@ fn run_diameter(s: &mut Session, p: &Value) -> Result { radial(s, p, true) } +fn hex_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) +} + fn run_angular(s: &mut Session, p: &Value) -> Result { + if let Some(ls) = p.get("lines").and_then(Value::as_array) { + let hs: Vec = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect(); + let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) }; + let at = point_req("dimangular", p, "at")?; + let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?; + return Ok(json!({ "handle": add_dim(s, k)?.hex() })); + } + if let Some(h) = hex_param(p, "arc") { + let at = point_req("dimangular", p, "at")?; + let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) { + Some(EntityKind::Arc(a)) => a, + _ => return Err(bad("dimangular", "object is not an arc")), + }; + let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); + let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""); + return Ok(json!({ "handle": add_dim(s, k)?.hex() })); + } let v = point_req("dimangular", p, "vertex")?; let a = point_req("dimangular", p, "p1")?; let b = point_req("dimangular", p, "p2")?; @@ -219,7 +325,16 @@ fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityK let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 }; defpt += n * (spacing * side); } - EntityKind::Dimension(Dimension { p13: v3(first), p14: v3(next), defpt: v3(defpt), text: String::new(), block: None, ..prev.clone() }) + EntityKind::Dimension(Dimension { + p13: v3(first), + p14: v3(next), + defpt: v3(defpt), + text: String::new(), + block: None, + assoc: Vec::new(), + user_text_pos: false, + ..prev.clone() + }) } fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result { @@ -280,7 +395,8 @@ fn run_qdim(s: &mut Session, p: &Value) -> Result { fn mleader_kind(s: &Session, pts: &[Vec2], text: &str) -> Option { let (landing, arrow_pts) = pts.split_last()?; let d = s.doc().ok()?; - let st = d.mleader_styles.first().cloned().unwrap_or_default(); + let cur = d.header.str("CMLEADERSTYLE", "Standard"); + let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default(); let k = d.header.f64("DIMSCALE", 1.0); let th = st.text_height * k; let dir = if arrow_pts.first().is_some_and(|a| a.x > landing.x) { -1.0 } else { 1.0 }; @@ -363,21 +479,309 @@ fn run_update(s: &mut Session, p: &Value) -> Result { Ok(json!({ "updated": n })) } +/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text. fn run_override(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let t = str_param(p, "text").unwrap_or("").to_string(); + if p.get("text").is_some() && p.as_object().is_some_and(|o| o.keys().all(|k| matches!(k.as_str(), "text" | "handles" | "handle"))) { + let hs = targets(s, p)?; + let t = str_param(p, "text").unwrap_or("").to_string(); + let d = s.doc_mut()?; + for h in &hs { + let _ = d.modify_entity(*h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.text = t.clone(); + dm.block = None; + } + }); + } + return Ok(json!({ "changed": hs.len() })); + } + super::props::dim_override(s, p) +} + +/// The two points a picked object is dimensioned between (DIMLINEAR "select object"). +fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> { + let e = s.doc().ok()?.entity(h)?; + if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) { + return Some((c - Vec2::X * r, c + Vec2::X * r)); + } + e.kind.prims().into_iter().find_map(|pr| match pr { + Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())), + _ => None, + }) +} + +fn dims_of(s: &Session, hs: &[Handle]) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect() +} + +fn run_reassociate(s: &mut Session, p: &Value) -> Result { + let hs = dims_of(s, &targets(s, p)?); + let space = s.space(); + let mut plan = Vec::new(); + { + let d = s.doc()?; + for h in &hs { + if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) { + plan.push((*h, crate::assoc::reassociate(d, &space, dm))); + } + } + } + let mut points = 0; + let mut n = 0; let d = s.doc_mut()?; - for h in &hs { - let _ = d.modify_entity(*h, |e| { + for (h, a) in plan { + points += a.len(); + n += usize::from(!a.is_empty()); + let _ = d.modify_entity(h, |e| { if let EntityKind::Dimension(dm) = &mut e.kind { - dm.text = t.clone(); - dm.block = None; + dm.assoc = a; } }); } + Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) })) +} + +fn run_disassociate(s: &mut Session, p: &Value) -> Result { + let hs = dims_of(s, &targets(s, p)?); + let d = s.doc_mut()?; + let mut n = 0; + for h in &hs { + let _ = d.modify_entity(*h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind + && !dm.assoc.is_empty() + { + dm.assoc.clear(); + n += 1; + } + }); + } + Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") })) +} + +/// DIMTEDIT: move/rotate/justify dimension text. +fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result { + let mut q = p.as_object().cloned().unwrap_or_default(); + q.insert("mode".into(), json!(mode)); + run_dimtedit(s, &Value::Object(q)) +} + +fn run_dimtedit(s: &mut Session, p: &Value) -> Result { + let hs = dims_of(s, &targets(s, p)?); + if hs.is_empty() { + return Err(bad("dimtedit", "select dimensions")); + } + let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase(); + let at = point_param(p, "at"); + let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians); + if mode == "angle" && angle.is_none() { + return Err(bad("dimtedit", "`angle` (degrees) is required")); + } + let d = s.doc_mut()?; + for h in &hs { + d.modify_entity(*h, |e| { + let EntityKind::Dimension(dm) = &mut e.kind else { return }; + dm.block = None; + if let Some(q) = at { + dm.text_mid = v3(q); + dm.user_text_pos = true; + } + match mode.as_str() { + "home" => { + dm.user_text_pos = false; + dm.text_rotation = 0.0; + dm.overrides.remove("textJust"); + } + "angle" => dm.text_rotation = angle.unwrap_or(0.0), + "left" | "center" | "right" => { + dm.user_text_pos = false; + let j = match mode.as_str() { + "left" => 1, + "right" => 2, + _ => 0, + }; + dm.overrides.insert("textJust".into(), json!(j)); + } + _ => {} + } + })?; + } Ok(json!({ "changed": hs.len() })) } +/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension. +fn run_dimspace(s: &mut Session, p: &Value) -> Result { + let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?; + let hs: Vec = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect(); + let d = s.doc()?; + let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) }; + let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides); + let spacing = + p.get("spacing").and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(st.baseline_spacing * st.scale.max(1e-9) * 2.0); + let dir = match b.kind { + DimKind::Linear { rotation } => Vec2::from_angle(rotation), + DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(), + _ => return Err(bad("dimspace", "base must be a linear or aligned dimension")), + }; + let n = dir.perp(); + let base_off = (b.defpt.xy() - b.p13.xy()).dot(n); + let side = if base_off >= 0.0 { 1.0 } else { -1.0 }; + // Order the others by their current distance from the base line. + let mut others: Vec<(Handle, f64)> = hs + .iter() + .filter_map(|h| match d.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => { + Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side)) + } + _ => None, + }) + .collect(); + others.sort_by(|a, c| a.1.total_cmp(&c.1)); + let line_pt = b.defpt.xy(); + let dm = s.doc_mut()?; + for (i, (h, _)) in others.iter().enumerate() { + let target = line_pt + n * (side * spacing * (i as f64 + 1.0)); + dm.modify_entity(*h, |e| { + if let EntityKind::Dimension(o) = &mut e.kind { + let cur = o.defpt.xy(); + let moved = cur + n * (target - cur).dot(n); + o.defpt = v3(moved); + o.user_text_pos = false; + o.block = None; + } + })?; + } + Ok(json!({ "spaced": others.len() })) +} + +/// Select objects, then run a JSON command on them. +struct SelectThen { + sel: super::machines::SelectPhase, + id: &'static str, +} + +impl SelectThen { + fn new(_s: &Session, id: &'static str) -> Self { + SelectThen { sel: super::machines::SelectPhase::default(), id } + } + fn run(&self, s: &mut Session, hs: Vec) -> Result { + let hex: Vec = hs.iter().map(|h| h.hex()).collect(); + let r = s.execute(self.id, &json!({ "handles": hex }))?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + Ok(Step::Done) + } +} + +impl Interactive for SelectThen { + fn name(&self) -> &'static str { + if self.id == "dimreassociate" { "DIMREASSOCIATE" } else { "DIMDISASSOCIATE" } + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = super::machines::SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.run(s, hs); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + self.sel.prompt() + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.sel.feed(s, &i)? { + super::machines::SelOutcome::More => Ok(Step::Continue), + super::machines::SelOutcome::Empty => Ok(Step::Done), + super::machines::SelOutcome::Done(hs) => self.run(s, hs), + } + } + fn preview(&self, _s: &Session, _c: Vec2) -> Vec { + Vec::new() + } +} + +/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions. +struct OverrideM { + vars: serde_json::Map, + var: Option, + clear: bool, + selecting: Option, +} + +impl OverrideM { + fn new(_s: &Session) -> Self { + OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None } + } + fn apply(&self, s: &mut Session, hs: Vec) -> Result { + let mut p = self.vars.clone(); + p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); + if self.clear { + p.insert("clear".into(), json!(true)); + } + let r = super::props::dim_override(s, &Value::Object(p))?; + s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0))); + Ok(Step::Done) + } +} + +impl Interactive for OverrideM { + fn name(&self) -> &'static str { + "DIMOVERRIDE" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(sel) = &self.selecting { + return sel.prompt(); + } + match &self.var { + Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT), + None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(sel) = &mut self.selecting { + return match sel.feed(s, &i)? { + super::machines::SelOutcome::More => Ok(Step::Continue), + super::machines::SelOutcome::Empty => Ok(Step::Done), + super::machines::SelOutcome::Done(hs) => self.apply(s, hs), + }; + } + match (self.var.take(), i) { + (None, Input::Keyword(k)) if k.starts_with("Clear") => { + self.clear = true; + self.selecting = Some(super::machines::SelectPhase::default()); + } + (None, Input::Text(t)) => { + let t = t.trim().to_string(); + if t.eq_ignore_ascii_case("c") { + self.clear = true; + self.selecting = Some(super::machines::SelectPhase::default()); + } else if cadcraft_doc::DimStyle::field_name(&t).is_some() { + self.var = Some(t); + } else { + s.echo(format!("Unknown dimension variable \"{t}\".")); + } + } + (None, Input::Enter) => { + if self.vars.is_empty() { + return Ok(Step::Cancel); + } + self.selecting = Some(super::machines::SelectPhase::default()); + } + (Some(v), Input::Text(t)) => { + let val = t.trim().parse::().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim())); + self.vars.insert(v, val); + } + (Some(v), _) => self.var = Some(v), + _ => {} + } + Ok(Step::Continue) + } + fn preview(&self, _s: &Session, _c: Vec2) -> Vec { + Vec::new() + } +} + // ---------------- interactive ---------------- fn pick_at(s: &Session, p: Vec2) -> Option { @@ -464,7 +868,7 @@ impl Interactive for LinearM { Ok(Step::Continue) } (_, Input::Point(at)) => { - let (a, b) = (self.pts[0], self.pts[1]); + let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) }; let k = dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, &self.text); add_dim(s, k)?; if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { @@ -548,6 +952,7 @@ impl Interactive for RadialM { #[derive(Default)] struct AngularM { + handles: Vec, first: Option<(Vec2, Vec2)>, second: Option<(Vec2, Vec2)>, vertex_mode: Vec, @@ -597,7 +1002,12 @@ impl Interactive for AngularM { if let Some((v, a, b)) = self.geometry() { return match i { Input::Point(at) => { - add_dim(s, dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""))?; + let k = match (self.handles.as_slice(), self.arc) { + ([h1, h2], None) => angular_from_lines(s, *h1, *h2, at), + _ => None, + }; + let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "")); + add_dim(s, k)?; Ok(Step::Done) } Input::Enter => Ok(Step::Cancel), @@ -629,6 +1039,7 @@ impl Interactive for AngularM { self.arc = Some((g.center, g.start_point(), g.end_point())); } Some(EntityKind::Line(l)) => { + self.handles.push(h); let seg = (l.a.xy(), l.b.xy()); if self.first.is_none() { self.first = Some(seg); @@ -842,3 +1253,7 @@ impl Interactive for MLeaderM { mleader_kind(s, &pts, "").into_iter().collect() } } + +#[cfg(test)] +#[path = "annotate_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/annotate_tests.rs b/crates/engine/src/cmd/annotate_tests.rs new file mode 100644 index 00000000..89a79f04 --- /dev/null +++ b/crates/engine/src/cmd/annotate_tests.rs @@ -0,0 +1,326 @@ +use cadcraft_doc::{AssocSnap, DimKind, Dimension, EntityKind, Handle}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use crate::Session; + +fn h(v: &Value) -> Handle { + Handle::parse_hex(v["handle"].as_str().unwrap()).unwrap() +} + +fn dim(s: &Session, hd: Handle) -> Dimension { + match &s.doc().unwrap().entity(hd).unwrap().kind { + EntityKind::Dimension(d) => d.clone(), + other => panic!("not a dimension: {other:?}"), + } +} + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle { + s.execute("line", &json!({ "points": [a, b] })).unwrap(); + s.doc().unwrap().model.last().unwrap().handle +} + +fn near(a: Vec2, b: Vec2) -> bool { + a.dist(b) < 1e-9 +} + +#[test] +fn linear_dimension_follows_line_endpoint() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let d = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, -2] })).unwrap()); + let dm = dim(&s, d); + assert_eq!(dm.assoc.len(), 2, "both origins attach to the line: {:?}", dm.assoc); + assert!(dm.assoc.iter().all(|a| a.handle == l)); + // Move the line's end point: the dimension's second origin follows. + s.execute("properties.set", &json!({ "handles": [l.hex()], "end": [12, 0] })).unwrap(); + let dm = dim(&s, d); + assert!(near(dm.p14.xy(), Vec2::new(12.0, 0.0)), "{:?}", dm.p14); + assert!(near(dm.p13.xy(), Vec2::ZERO)); + // The dimension line stays where it was. + assert!(near(dm.defpt.xy(), Vec2::new(5.0, -2.0))); + let g = cadcraft_render::dimension_in(s.doc().unwrap(), &dm); + assert_eq!(g.value, "12.0000"); + // Undo restores both in one step. + s.undo().unwrap(); + assert!(near(dim(&s, d).p14.xy(), Vec2::new(10.0, 0.0))); +} + +#[test] +fn moving_the_object_carries_the_dimension() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let d = h(&s.execute("dimaligned", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, -2] })).unwrap()); + s.execute("move", &json!({ "handles": [l.hex()], "from": [0, 0], "to": [3, 4] })).unwrap(); + let dm = dim(&s, d); + assert!(near(dm.p13.xy(), Vec2::new(3.0, 4.0)) && near(dm.p14.xy(), Vec2::new(13.0, 4.0)), "{dm:?}"); + assert!(near(dm.defpt.xy(), Vec2::new(8.0, 2.0)), "rigid move carries the dimension line: {:?}", dm.defpt); + // Moving both together keeps them attached and unchanged relative to each other. + s.execute("move", &json!({ "handles": [l.hex(), d.hex()], "from": [0, 0], "to": [1, 0] })).unwrap(); + let dm = dim(&s, d); + assert_eq!(dm.assoc.len(), 2); + assert!(near(dm.p13.xy(), Vec2::new(4.0, 4.0)) && near(dm.defpt.xy(), Vec2::new(9.0, 2.0)), "{dm:?}"); +} + +#[test] +fn interactive_dimlinear_select_object_is_associative() { + let mut s = Session::new(); + s.cmdline("line 0,0 6,0").unwrap(); + s.cmdline("").unwrap(); + let l = s.doc().unwrap().model.last().unwrap().handle; + s.cmdline("dimlinear").unwrap(); + s.cmdline("").unwrap(); //