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 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 01:27:38 -04:00
1 parent c35fa82373
commit f9aa1b279a
42 files changed
+8497 -430

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"
+1 -1
View File
@@ -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/<app>-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`.
+1 -1
View File
@@ -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/<app>-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`.
Generated
+12
View File
@@ -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",
]
+1
View File
@@ -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" }
+14 -14
View File
@@ -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.
+15
View File
@@ -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 }
+204
View File
@@ -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<String>,
}
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<Constraint> {
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<Constraint> = Vec::new();
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
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<u32> {
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
}
+324
View File
@@ -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<Expr>),
Bin(char, Box<Expr>, Box<Expr>),
}
/// 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<u8> {
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<Expr, ExprError> {
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<Expr, ExprError> {
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<Expr, ExprError> {
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<Expr, ExprError> {
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<Expr, ExprError> {
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<String>) {
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<f64, ExprError>) -> Result<f64, ExprError> {
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<f64, ExprError> {
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<String, String>,
cache: BTreeMap<String, f64>,
}
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<f64, ExprError> {
let mut stack = Vec::new();
self.value_inner(name, &mut stack)
}
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
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<f64, ExprError> {
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()]);
}
}
+592
View File
@@ -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<u32> },
#[error("constraint would over-constrain the geometry (redundant with {})", ids_text(ids))]
Redundant { ids: Vec<u32> },
#[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::<Vec<_>>().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<Handle>,
/// Entities that should take up the change.
pub prefer_move: Vec<Handle>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct SolveReport {
/// Entities whose geometry changed.
pub moved: Vec<Handle>,
/// 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<f64, SolveError> {
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<f64>,
pub error: Option<String>,
/// `dimensional` or `user`.
pub kind: &'static str,
pub constraint: Option<u32>,
pub description: String,
}
pub fn parameter_table(d: &Drawing) -> Vec<ParamRow> {
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<Built>,
weights: Vec<f64>,
tol: f64,
}
impl Comp {
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
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<usize>) -> Vec<usize> {
self.locks(skip).iter().enumerate().filter(|(_, l)| !**l).map(|(i, _)| i).collect()
}
fn eval(&self, x: &[f64], out: &mut Vec<f64>, skip: Option<usize>) {
for (i, b) in self.built.iter().enumerate() {
if Some(i) != skip {
b.eval(&self.model, x, out);
}
}
}
fn rows(&self, skip: Option<usize>) -> usize {
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
}
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
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<f64>| 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<u32> {
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<Vec<&'a Constraint>> {
let mut parent: Vec<usize> = (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<Handle, usize> = 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<usize, Vec<&Constraint>> = 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<Comp>, 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<Handle> = 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<SolveReport, SolveError> {
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<f64>)> {
let mut e = env(d);
let handles: Vec<Handle> = 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<Handle> = 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<Handle> = 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<f64, String> {
let c = Constraint { id: 0, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() };
let handles: Vec<Handle> = 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<u32> {
let mut gone = Vec::new();
let keep: Vec<bool> = 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<u32, SolveError> {
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<f64>| {
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<Handle> = 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<SolveReport, SolveError> {
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<String>,
pub anchors: Vec<Vec2>,
pub name: String,
pub expr: String,
pub value: Option<f64>,
pub satisfied: bool,
}
/// Glyph anchors for every constraint (for the constraint bars / dynamic constraints UI).
pub fn glyphs(d: &Drawing) -> Vec<Glyph> {
let handles: Vec<Handle> = 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<u32, Option<f64>> = 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;
+271
View File
@@ -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<f64>,
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::<f64>()
}
/// Numerical Jacobian over the free parameters (row-major, rows × free.len()).
pub(crate) fn jacobian(f: &dyn Fn(&[f64], &mut Vec<f64>), x: &[f64], free: &[usize], rows: usize) -> Vec<f64> {
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<f64>, mut b: Vec<f64>, n: usize) -> Option<Vec<f64>> {
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<Vec<f64>> = (0..rows)
.map(|i| {
let r: Vec<f64> = 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<f64>), 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<f64> = 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<f64>| 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<f64>| {
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());
}
}
+260
View File
@@ -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<Slot>,
pub x0: Vec<f64>,
index: HashMap<Handle, usize>,
}
/// 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<Vec2> {
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<Vec2> {
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<f64> {
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<Vec<usize>> {
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<Handle> {
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
}
}
+392
View File
@@ -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<GeomRef>,
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<Built, String> {
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<f64>) {
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<f64>) -> 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 }
}
}
+435
View File
@@ -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<String> = 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<Vec2> = 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());
}
+227
View File
@@ -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<Sub> {
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<GeomRef>,
/// 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<String>,
/// 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<Handle>,
/// Dynamic dimensional constraints shown.
pub dims_visible: bool,
pub dim_exceptions: Vec<Handle>,
/// 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<Parameter>,
pub settings: ParametricSettings,
}
+49
View File
@@ -329,6 +329,49 @@ pub struct Dimension {
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// 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<String, serde_json::Value>,
/// Associativity: which definition points follow which objects.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assoc: Vec<DimAssoc>,
}
/// 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
+8
View File
@@ -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<Ucs>,
pub layer_states: Vec<LayerState>,
pub groups: Vec<Group>,
/// Parametric constraints (GEOMCONSTRAINT / DIMCONSTRAINT).
pub constraints: Vec<Constraint>,
/// 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,
}
}
+165
View File
@@ -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<String, serde_json::Value>) -> Vec<String> {
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::<f64>() {
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::<DimStyle>(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<String, serde_json::Value>) -> 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 {
+1
View File
@@ -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 }
+294
View File
@@ -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<Vec3> {
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<Vec2> {
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<DimAssoc> {
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<f64>) -> Option<Handle> {
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<DimAssoc> {
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<DimAssoc> {
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<Vec2> = Vec::new();
let mut moved: Vec<String> = 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<Drawing>, 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<Drawing>>, uid: Option<u64>) {
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);
}
}
+432 -17
View File
@@ -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> {
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> {
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<CommandSpec> {
.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?, <DimStyle fields or DIM* variables: values>, 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<f64>, 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<Handle> {
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<DimAssoc>) -> 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<EntityKind> {
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<Value> {
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<Value> {
radial(s, p, true)
}
fn hex_param(p: &Value, key: &str) -> Option<Handle> {
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<Value> {
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
let hs: Vec<Handle> = 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<Value> {
@@ -280,7 +395,8 @@ fn run_qdim(s: &mut Session, p: &Value) -> Result<Value> {
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str) -> Option<EntityKind> {
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<Value> {
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<Value> {
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<Handle> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?;
let hs: Vec<Handle> = 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<Handle>) -> Result<Step> {
let hex: Vec<String> = 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<Step> {
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<Step> {
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<EntityKind> {
Vec::new()
}
}
/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions.
struct OverrideM {
vars: serde_json::Map<String, Value>,
var: Option<String>,
clear: bool,
selecting: Option<super::machines::SelectPhase>,
}
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<Handle>) -> Result<Step> {
let mut p = self.vars.clone();
p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
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<Step> {
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::<f64>().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<EntityKind> {
Vec::new()
}
}
// ---------------- interactive ----------------
fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
@@ -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<Handle>,
first: Option<(Vec2, Vec2)>,
second: Option<(Vec2, Vec2)>,
vertex_mode: Vec<Vec2>,
@@ -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;
+326
View File
@@ -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(); // <select object>
s.cmdline("3,0").unwrap();
s.cmdline("3,-1").unwrap();
assert!(s.running.is_none());
let d = s.last_dim.unwrap();
assert_eq!(dim(&s, d).assoc.len(), 2);
// A grip-style edit through properties.
s.execute("properties.set", &json!({ "handles": [l.hex()], "start": [-1, 0] })).unwrap();
assert!(near(dim(&s, d).p13.xy(), Vec2::new(-1.0, 0.0)));
}
#[test]
fn radius_and_diameter_follow_circle() {
let mut s = Session::new();
s.execute("circle", &json!({ "center": [0, 0], "radius": 2 })).unwrap();
let c = s.doc().unwrap().model.last().unwrap().handle;
let r = h(&s.execute("dimradius", &json!({ "handle": c.hex(), "at": [5, 0] })).unwrap());
let dd = h(&s.execute("dimdiameter", &json!({ "handle": c.hex(), "at": [0, 5] })).unwrap());
assert_eq!(dim(&s, r).assoc.len(), 2);
assert_eq!(dim(&s, dd).assoc.len(), 2);
s.execute("properties.set", &json!({ "handles": [c.hex()], "radius": 3 })).unwrap();
let g = cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, r));
assert_eq!(g.value, "R3.0000");
let g = cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, dd));
assert_eq!(g.value, "⌀6.0000");
s.execute("move", &json!({ "handles": [c.hex()], "from": [0, 0], "to": [10, 0] })).unwrap();
assert!(near(dim(&s, r).defpt.xy(), Vec2::new(10.0, 0.0)));
}
#[test]
fn angular_between_lines_follows_rotation() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
let d = h(&s.execute("dimangular", &json!({ "lines": [a.hex(), b.hex()], "at": [3, 3] })).unwrap());
let dm = dim(&s, d);
assert!(dm.assoc.iter().any(|x| matches!(x.snap, AssocSnap::Intersection { .. })));
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°");
// Swing the second line to 45°.
s.execute("properties.set", &json!({ "handles": [b.hex()], "end": [10, 10] })).unwrap();
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "45°");
}
#[test]
fn erase_disassociate_and_reassociate() {
let mut s = Session::new();
let l = line(&mut s, [0.0, 0.0], [4.0, 0.0]);
let d = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [4, 0], "at": [2, 1] })).unwrap());
s.execute("dimdisassociate", &json!({ "handles": [d.hex()] })).unwrap();
assert!(dim(&s, d).assoc.is_empty());
s.execute("properties.set", &json!({ "handles": [l.hex()], "end": [5, 0] })).unwrap();
assert!(near(dim(&s, d).p14.xy(), Vec2::new(4.0, 0.0)), "not associative any more");
// Put the line back and reassociate.
s.execute("properties.set", &json!({ "handles": [l.hex()], "end": [4, 0] })).unwrap();
let r = s.execute("dimreassociate", &json!({ "handles": [d.hex()] })).unwrap();
assert_eq!(r["associated"], 1);
assert_eq!(dim(&s, d).assoc.len(), 2);
// Erasing the object leaves the dimension where it is (and no longer attached).
s.execute("erase", &json!({ "handles": [l.hex()] })).unwrap();
let dm = dim(&s, d);
assert!(dm.assoc.is_empty());
assert!(near(dm.p14.xy(), Vec2::new(4.0, 0.0)));
}
#[test]
fn dimassoc_zero_makes_plain_dimensions() {
let mut s = Session::new();
line(&mut s, [0.0, 0.0], [4.0, 0.0]);
s.doc_mut().unwrap().header.set_i64("DIMASSOC", 0);
let d = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [4, 0], "at": [2, 1] })).unwrap());
assert!(dim(&s, d).assoc.is_empty());
}
#[test]
fn dimstyle_commands() {
let mut s = Session::new();
let r = s.execute("dimstyle", &json!({ "name": "Arch", "DIMTSZ": 0.1, "DIMBLK": "_ARCHTICK", "DIMLUNIT": 4, "DIMTAD": 1, "bogus": 3 })).unwrap();
assert_eq!(r["ignored"], json!(["bogus"]));
let st = s.doc().unwrap().dim_style("Arch").unwrap().clone();
assert_eq!(st.tick_size, 0.1);
assert_eq!(st.arrow_block, "_ARCHTICK");
assert_eq!(st.linear_unit, 4);
assert_eq!(st.text_above, 1);
let l = s.execute("dimstyle.list", &json!({ "name": "Arch" })).unwrap();
assert_eq!(l["variables"]["DIMTSZ"], json!(0.1));
assert_eq!(l["current"], "Arch");
let d = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [18.5, 0], "at": [5, 2] })).unwrap());
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "1'-6 1/2\"");
// In use: cannot delete; rename follows into dimensions.
s.execute("dimstyle.current", &json!({ "name": "Standard" })).unwrap();
assert!(s.execute("dimstyle.delete", &json!({ "name": "Arch" })).is_err());
s.execute("dimstyle.rename", &json!({ "from": "Arch", "to": "Architectural" })).unwrap();
assert_eq!(dim(&s, d).style, "Architectural");
s.execute("erase", &json!({ "handles": [d.hex()] })).unwrap();
s.execute("dimstyle.delete", &json!({ "name": "Architectural" })).unwrap();
assert!(s.doc().unwrap().dim_style("Architectural").is_none());
assert!(s.execute("dimstyle.delete", &json!({ "name": "Standard" })).is_err());
}
#[test]
fn dimension_overrides() {
let mut s = Session::new();
let d = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 2] })).unwrap());
s.execute("dimoverride", &json!({ "handles": [d.hex()], "DIMDEC": 1, "DIMPOST": "<> mm" })).unwrap();
let dm = dim(&s, d);
assert_eq!(dm.overrides.get("decimals"), Some(&json!(1)));
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "10.0 mm");
// The interactive form.
s.cmdline("dimoverride").unwrap();
s.cmdline("DIMDEC").unwrap();
s.cmdline("2").unwrap();
s.cmdline("").unwrap();
s.input(crate::Input::Pick(vec![d])).unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "10.00 mm");
s.execute("dimstyle.override", &json!({ "handles": [d.hex()], "clear": true })).unwrap();
assert!(dim(&s, d).overrides.is_empty());
assert!(s.execute("dimoverride", &json!({ "handles": [d.hex()], "nope": 1 })).is_err());
// Plain text override keeps working.
s.execute("dimoverride", &json!({ "handles": [d.hex()], "text": "<> TYP" })).unwrap();
assert_eq!(dim(&s, d).text, "<> TYP");
}
#[test]
fn dimtedit_and_dimspace() {
let mut s = Session::new();
let a = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 1] })).unwrap());
let b = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [6, 0], "at": [3, 1.3] })).unwrap());
s.execute("dimtedit.left", &json!({ "handles": [a.hex()] })).unwrap();
let g = cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, a));
assert!(g.text_pos.x < 5.0);
s.execute("dimtedit.angle", &json!({ "handles": [a.hex()], "angle": 30 })).unwrap();
let g = cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, a));
assert!((g.text_angle - 30f64.to_radians()).abs() < 1e-9);
s.execute("dimtedit.home", &json!({ "handles": [a.hex()] })).unwrap();
assert!(dim(&s, a).overrides.is_empty() && dim(&s, a).text_rotation == 0.0);
s.execute("dimspace", &json!({ "base": a.hex(), "handles": [b.hex()], "spacing": 0.5 })).unwrap();
assert!((dim(&s, b).defpt.y - 1.5).abs() < 1e-9, "{:?}", dim(&s, b).defpt);
}
#[test]
fn text_style_commands() {
let mut s = Session::new();
let r = s
.execute(
"style",
&json!({ "name": "Notes", "font": "Arial", "height": 0.25, "widthFactor": 0.8, "oblique": 15, "backwards": true, "upsideDown": true }),
)
.unwrap();
assert_eq!(r["current"], "Notes");
let st = s.doc().unwrap().text_style("Notes").unwrap().clone();
assert!(st.backwards && st.upside_down);
assert!((st.oblique.to_degrees() - 15.0).abs() < 1e-9);
s.execute("text", &json!({ "at": [0, 0], "height": 0.25, "text": "X" })).unwrap();
let l = s.execute("style.list", &json!({})).unwrap();
assert_eq!(l["styles"].as_array().unwrap().len(), 2);
s.execute("style.rename", &json!({ "from": "Notes", "to": "Labels" })).unwrap();
assert_eq!(s.doc().unwrap().header.str("TEXTSTYLE", ""), "Labels");
assert!(s.execute("style.delete", &json!({ "name": "Labels" })).is_err(), "current style");
s.execute("style.current", &json!({ "name": "Standard" })).unwrap();
let used = s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.style == "Labels"));
if used {
assert!(s.execute("style.delete", &json!({ "name": "Labels" })).is_err(), "in use");
} else {
s.execute("style.delete", &json!({ "name": "Labels" })).unwrap();
}
assert!(s.execute("style.delete", &json!({ "name": "Standard" })).is_err());
}
#[test]
fn table_create_edit_merge() {
let mut s = Session::new();
let t = h(&s
.execute(
"table",
&json!({ "at": [0, 10], "rows": 2, "cols": 3, "title": "Parts", "header": ["No", "Name", "Qty"], "cells": [["1", "Bolt", "4"]] }),
)
.unwrap());
let get = |s: &Session| match &s.doc().unwrap().entity(t).unwrap().kind {
EntityKind::Table(x) => x.clone(),
_ => panic!(),
};
let tb = get(&s);
assert_eq!(tb.row_heights.len(), 4);
assert_eq!(tb.col_widths.len(), 3);
assert_eq!(tb.cells[0][0].merged, Some((1, 3)));
assert_eq!(tb.cells[2][1].text, "Bolt");
s.execute("table.set", &json!({ "handle": t.hex(), "row": 3, "col": 1, "text": "Nut" })).unwrap();
assert_eq!(get(&s).cells[3][1].text, "Nut");
// Writing into a covered title cell writes the title.
s.execute("table.set", &json!({ "handle": t.hex(), "row": 0, "col": 2, "text": "Bill of materials" })).unwrap();
assert_eq!(get(&s).cells[0][0].text, "Bill of materials");
s.execute("table.insertcol", &json!({ "handle": t.hex(), "col": 1 })).unwrap();
let tb = get(&s);
assert_eq!(tb.col_widths.len(), 4);
assert_eq!(tb.cells[0][0].merged, Some((1, 4)), "title merge grows");
assert_eq!(tb.cells[2][2].text, "Bolt");
s.execute("table.deletecol", &json!({ "handle": t.hex(), "col": 1 })).unwrap();
s.execute("table.insertrow", &json!({ "handle": t.hex() })).unwrap();
assert_eq!(get(&s).row_heights.len(), 5);
s.execute("table.merge", &json!({ "handle": t.hex(), "row": 2, "col": 0, "rows": 2, "cols": 1 })).unwrap();
let tb = get(&s);
assert_eq!(tb.cells[2][0].merged, Some((2, 1)));
assert!(s.execute("table.deleterow", &json!({ "handle": t.hex(), "row": 2 })).is_ok());
assert_eq!(get(&s).cells[2][0].merged, None, "a one-row merge is no merge");
s.execute("table.unmerge", &json!({ "handle": t.hex(), "row": 0, "col": 1 })).unwrap();
assert_eq!(get(&s).cells[0][0].merged, None);
assert!(s.execute("table.set", &json!({ "handle": t.hex(), "row": 99, "col": 0, "text": "x" })).is_err());
assert!(s.execute("table", &json!({ "at": [0, 0], "rows": 1_000_000, "cols": 3 })).is_err());
// It renders: grid lines plus text.
let dl = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
assert!(dl.prims.len() > 10);
}
#[test]
fn table_interactive() {
let mut s = Session::new();
s.cmdline("table").unwrap();
s.cmdline("3").unwrap();
s.cmdline("2").unwrap();
s.cmdline("0,0").unwrap();
assert!(s.running.is_none());
match &s.doc().unwrap().model.last().unwrap().kind {
EntityKind::Table(t) => {
assert_eq!(t.col_widths.len(), 3);
assert_eq!(t.row_heights.len(), 4, "title + header + 2 data rows");
}
other => panic!("{other:?}"),
}
}
#[test]
fn hostile_annotation_params_never_panic() {
let mut s = Session::new();
for (id, p) in [
("dimstyle", json!({ "name": "X", "DIMDEC": -5, "DIMTAD": 1e300, "DIMCLRD": "nonsense", "DIMBLK": 7 })),
("dimoverride", json!({ "handles": ["zz"], "DIMDEC": 1 })),
("dimreassociate", json!({ "handles": [1, 2, 3] })),
("dimangular", json!({ "lines": ["1"], "at": [0, 0] })),
("dimlinear", json!({ "object": "ffff", "at": [0, 0] })),
("table.merge", json!({ "handle": "1", "row": 0, "col": 0, "rows": 1e9, "cols": -1 })),
("table", json!({ "at": [0, 0], "cols": 0 })),
("style", json!({ "name": "" })),
("style.rename", json!({ "from": "Standard", "to": "Y" })),
("dimspace", json!({ "base": "1", "handles": [] })),
("dimtedit", json!({ "handles": [], "mode": "angle" })),
] {
let _ = s.execute(id, &p);
}
let d = h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [1, 0], "at": [0, 1] })).unwrap());
let _ = s.execute("dimoverride", &json!({ "handles": [d.hex()], "DIMTXT": -1, "DIMSCALE": 0, "DIMLUNIT": 99 }));
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
assert!(matches!(dim(&s, d).kind, DimKind::Linear { .. }));
}
+981
View File
@@ -0,0 +1,981 @@
//! Parametric drawing: GEOMCONSTRAINT and the GC* commands, DIMCONSTRAINT and the DC* commands,
//! AUTOCONSTRAIN, DELCONSTRAINT, PARAMETERS, CONSTRAINTBAR, DCDISPLAY, CONSTRAINTSETTINGS and
//! `constraints.inspect`, plus the post-command re-solve hook ([`after_command`]).
//!
//! The solver lives in `cadcraft-constraints`; this module turns picks and JSON parameters into
//! constraint references and keeps the drawing's constraints satisfied after edits.
use std::sync::Arc;
use cadcraft_constraints as cs;
use cadcraft_doc::{Constraint, ConstraintKind as K, DistAxis, Drawing, EntityKind, GeomRef, Handle, Sub};
use cadcraft_geom::{Line, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
macro_rules! gc {
($id:literal, $label:literal, $kind:expr, $menu:literal) => {
CommandSpec::new($id, $label, |s, p| run_gc(s, p, $kind))
.menu(&["Tools", "Parametric", "Geometric Constraints", $menu])
.params("{h1, p1?, h2?, p2?, h3?, p3?} (h = handle; p = \"start\"/\"end\"/\"mid\"/\"center\"/\"v<i>\"/\"seg<i>\"/\"object\" or a pick point [x, y])")
.interactive(|_| Ok(Box::new(GcM::new($kind))))
};
}
macro_rules! dc {
($id:literal, $label:literal, $dc:expr, $menu:literal) => {
CommandSpec::new($id, $label, |s, p| run_dc(s, p, $dc))
.menu(&["Tools", "Parametric", "Dimensional Constraints", $menu])
.params("{h1, p1?, h2?, p2?, expr? | value?, name?}")
.interactive(|_| Ok(Box::new(DcM::new(Some($dc)))))
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("geomconstraint", "Geometric Constraint", run_geomconstraint)
.alias(&["gcon"])
.params("{type: coincident|collinear|concentric|fix|parallel|perpendicular|horizontal|vertical|tangent|smooth|symmetric|equal, h1, p1?, h2?, p2?, h3?, p3?}")
.interactive(|_| Ok(Box::new(GeomM::default()))),
gc!("gccoincident", "Coincident", K::Coincident, "Coincident"),
gc!("gcperpendicular", "Perpendicular", K::Perpendicular, "Perpendicular"),
gc!("gcparallel", "Parallel", K::Parallel, "Parallel"),
gc!("gctangent", "Tangent", K::Tangent, "Tangent"),
gc!("gchorizontal", "Horizontal", K::Horizontal, "Horizontal"),
gc!("gcvertical", "Vertical", K::Vertical, "Vertical"),
gc!("gccollinear", "Collinear", K::Collinear, "Collinear"),
gc!("gcconcentric", "Concentric", K::Concentric, "Concentric"),
gc!("gcsmooth", "Smooth", K::Smooth, "Smooth"),
gc!("gcsymmetric", "Symmetric", K::Symmetric, "Symmetric"),
gc!("gcequal", "Equal", K::Equal, "Equal"),
gc!("gcfix", "Fix", K::Fix, "Fix"),
CommandSpec::new("autoconstrain", "AutoConstrain", run_autoconstrain)
.menu(&["Tools", "Parametric", "AutoConstrain"])
.params("{handles?, tolerance?, angleTolerance? (degrees), types?: [names]}")
.interactive(|_| Ok(Box::new(SelM::new(SelOp::Auto)))),
CommandSpec::new("constraintbar", "Constraint Bars", run_constraintbar)
.menu(&["Tools", "Parametric", "Constraint Bars", "Select Objects"])
.params("{mode?: show|hide|showall|hideall, handles?}")
.interactive(|_| Ok(Box::new(SelM::new(SelOp::Bars)))),
CommandSpec::new("constraintbar.showall", "Show All Constraint Bars", |s, _| set_bars(s, false, "showall", &[]))
.menu(&["Tools", "Parametric", "Constraint Bars", "Show All"]),
CommandSpec::new("constraintbar.hideall", "Hide All Constraint Bars", |s, _| set_bars(s, false, "hideall", &[]))
.menu(&["Tools", "Parametric", "Constraint Bars", "Hide All"]),
CommandSpec::new("dimconstraint", "Dimensional Constraint", run_dimconstraint)
.alias(&["dcon"])
.params("{type: linear|aligned|horizontal|vertical|angular|radius|diameter, h1, p1?, h2?, p2?, expr? | value?, name?}")
.interactive(|_| Ok(Box::new(DcM::new(None)))),
dc!("dcaligned", "Aligned", Dc::Aligned, "Aligned"),
dc!("dchorizontal", "Horizontal", Dc::Horizontal, "Horizontal"),
dc!("dcvertical", "Vertical", Dc::Vertical, "Vertical"),
dc!("dcangular", "Angular", Dc::Angular, "Angular"),
dc!("dcradius", "Radius", Dc::Radius, "Radius"),
dc!("dcdiameter", "Diameter", Dc::Diameter, "Diameter"),
CommandSpec::new("dclinear", "Linear", |s, p| run_dc(s, p, Dc::Linear))
.params("{h1, p1?, h2?, p2?, expr? | value?, name?}")
.interactive(|_| Ok(Box::new(DcM::new(Some(Dc::Linear))))),
CommandSpec::new("dcdisplay", "Dynamic Dimensions", run_dcdisplay)
.menu(&["Tools", "Parametric", "Dynamic Dimensions", "Select Objects"])
.params("{mode?: show|hide|showall|hideall, handles?}")
.interactive(|_| Ok(Box::new(SelM::new(SelOp::Dims)))),
CommandSpec::new("dcdisplay.showall", "Show All Dynamic Dimensions", |s, _| set_bars(s, true, "showall", &[]))
.menu(&["Tools", "Parametric", "Dynamic Dimensions", "Show All"]),
CommandSpec::new("dcdisplay.hideall", "Hide All Dynamic Dimensions", |s, _| set_bars(s, true, "hideall", &[]))
.menu(&["Tools", "Parametric", "Dynamic Dimensions", "Hide All"]),
CommandSpec::new("delconstraint", "Delete Constraints", run_delconstraint)
.menu(&["Tools", "Parametric", "Delete Constraints"])
.params("{handles? | ids?: [n]}")
.interactive(|_| Ok(Box::new(SelM::new(SelOp::Delete)))),
CommandSpec::new("parameters", "Parameters Manager", run_parameters)
.menu(&["Tools", "Parametric", "Parameters Manager"])
.alias(&["par", "parametersclose"])
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}"),
CommandSpec::new("constraintsettings", "Constraint Settings", run_constraintsettings)
.menu(&["Tools", "Parametric", "Constraint Settings"])
.alias(&["csettings"])
.params("{infer?, distanceTolerance?, angleTolerance?, autoTypes?: [names], barsVisible?, barTransparency?, dimsVisible?}"),
CommandSpec::new("constraints.inspect", "Inspect Constraints", run_inspect).noundo().params("{} → constraints, glyph anchors, parameters, settings"),
]
}
fn err(e: cs::SolveError) -> EngineError {
EngineError::Other(e.to_string())
}
fn h_param(p: &Value, k: &str) -> Option<Handle> {
p.get(k).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
}
// ---------------- references ----------------
/// What a constraint slot needs from the picked object.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Want {
Point,
Line,
Curve,
/// A line, polyline segment, circle, arc or point object.
Object,
/// A characteristic point near the pick, else the object.
PointOrObject,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Hint {
None,
Sub(Sub),
Pick(Vec2),
}
fn hint_param(cmd: &str, p: &Value, key: &str) -> Result<Hint> {
match p.get(key) {
None | Some(Value::Null) => Ok(Hint::None),
Some(Value::String(t)) => match Sub::parse(t) {
Some(sub) => Ok(Hint::Sub(sub)),
None => point_value(&Value::String(t.clone())).map(Hint::Pick).ok_or_else(|| bad(cmd, format!("`{key}`: unknown point `{t}`"))),
},
Some(v) => point_value(v).map(Hint::Pick).ok_or_else(|| bad(cmd, format!("`{key}` must be a point name or [x, y]"))),
}
}
/// Characteristic points of an object (end points, mid points, centres, vertices).
fn char_points(kind: &EntityKind) -> Vec<(Sub, Vec2)> {
match kind {
EntityKind::Line(l) => vec![(Sub::Start, l.a.xy()), (Sub::End, l.b.xy()), (Sub::Mid, l.a.xy().mid(l.b.xy()))],
EntityKind::Arc(a) => {
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
vec![(Sub::Start, g.start_point()), (Sub::End, g.end_point()), (Sub::Mid, g.mid_point()), (Sub::Center, g.center)]
}
EntityKind::Circle(c) => vec![(Sub::Center, c.center.xy())],
EntityKind::Point(p) => vec![(Sub::Whole, p.p.xy())],
EntityKind::LwPolyline(p) => p.vertices.iter().take(100_000).enumerate().map(|(i, v)| (Sub::Vertex(i as u32), v.p)).collect(),
_ => Vec::new(),
}
}
/// Line-like parts of an object (a line, or each polyline segment).
fn line_parts(kind: &EntityKind) -> Vec<(Sub, Vec2, Vec2)> {
match kind {
EntityKind::Line(l) => vec![(Sub::Whole, l.a.xy(), l.b.xy())],
EntityKind::LwPolyline(p) => {
let n = p.vertices.len().min(100_000);
let segs = if p.closed && n > 2 { n } else { n.saturating_sub(1) };
(0..segs).filter_map(|i| Some((Sub::Segment(i as u32), p.vertices.get(i)?.p, p.vertices.get((i + 1) % n)?.p))).collect()
}
_ => Vec::new(),
}
}
fn object_size(kind: &EntityKind) -> f64 {
match kind {
EntityKind::Line(l) => l.a.xy().dist(l.b.xy()),
EntityKind::Arc(a) => a.radius,
EntityKind::Circle(c) => c.radius,
EntityKind::LwPolyline(p) => p.vertices.windows(2).filter_map(|w| Some(w.first()?.p.dist(w.get(1)?.p))).fold(0.0, f64::max),
_ => 0.0,
}
.abs()
}
fn resolve(d: &Drawing, h: Handle, hint: Hint, want: Want, aperture: f64) -> std::result::Result<GeomRef, String> {
let e = d.entity(h).ok_or_else(|| format!("no such object {}", h.hex()))?;
if !cs::supported(&e.kind) {
return Err(format!("{} objects cannot be constrained", e.kind.type_name()));
}
let k = &e.kind;
let object = |at: Option<Vec2>| -> Option<Sub> {
let parts = line_parts(k);
if !parts.is_empty() {
let best = match at {
Some(p) => parts.iter().min_by(|a, b| Line::new(a.1, a.2).dist(p).total_cmp(&Line::new(b.1, b.2).dist(p))),
None => parts.first(),
};
return best.map(|b| b.0);
}
matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::Point(_)).then_some(Sub::Whole)
};
let sub = match (hint, want) {
(Hint::Sub(s), _) => Some(s),
(Hint::None, Want::Point) => char_points(k).first().map(|c| c.0),
(Hint::None, _) => object(None),
(Hint::Pick(p), Want::Point) => {
char_points(k).into_iter().filter(|c| c.1.is_finite()).min_by(|a, b| a.1.dist(p).total_cmp(&b.1.dist(p))).map(|c| c.0)
}
(Hint::Pick(p), Want::PointOrObject) => {
let near = char_points(k).into_iter().filter(|c| c.1.is_finite()).min_by(|a, b| a.1.dist(p).total_cmp(&b.1.dist(p)));
let limit = (aperture * 2.0).max(object_size(k) * 0.15);
match near {
Some((s, q)) if q.dist(p) <= limit => Some(s),
_ => object(Some(p)),
}
}
(Hint::Pick(p), _) => object(Some(p)),
};
let sub = sub.ok_or_else(|| format!("cannot use this {} here", k.type_name()))?;
// Shape checks for clearer messages.
let ok = match want {
Want::Line => !line_parts(k).is_empty() && matches!(sub, Sub::Whole | Sub::Segment(_)),
Want::Curve => matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)),
_ => true,
};
if !ok {
return Err(match want {
Want::Line => "Select a line or polyline segment".into(),
_ => "Select an arc or circle".into(),
});
}
Ok(GeomRef::new(h, sub))
}
/// The closest pair of characteristic points of two objects (COINCIDENT with no hints).
fn closest_points(d: &Drawing, a: Handle, b: Handle) -> Option<(GeomRef, GeomRef)> {
let (ea, eb) = (d.entity(a)?, d.entity(b)?);
let pa: Vec<_> = char_points(&ea.kind).into_iter().filter(|c| c.0 != Sub::Mid).collect();
let pb: Vec<_> = char_points(&eb.kind).into_iter().filter(|c| c.0 != Sub::Mid).collect();
let mut best: Option<(f64, Sub, Sub)> = None;
for (sa, qa) in pa.iter().take(1000) {
for (sb, qb) in pb.iter().take(1000) {
let dd = qa.dist(*qb);
if dd.is_finite() && best.is_none_or(|b| dd < b.0) {
best = Some((dd, *sa, *sb));
}
}
}
best.map(|(_, sa, sb)| (GeomRef::new(a, sa), GeomRef::new(b, sb)))
}
fn wants(kind: K, n: usize) -> Vec<Want> {
match kind {
K::Coincident => vec![Want::PointOrObject; 2],
K::Horizontal | K::Vertical => {
if n >= 2 {
vec![Want::Point; 2]
} else {
vec![Want::Line]
}
}
K::Parallel | K::Perpendicular | K::Collinear => vec![Want::Line; 2],
K::Concentric => vec![Want::Curve; 2],
K::Symmetric => vec![Want::Object, Want::Object, Want::Line],
K::Fix => vec![Want::PointOrObject],
_ => vec![Want::Object; 2],
}
}
fn aperture(s: &Session) -> f64 {
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
}
fn build_refs(s: &Session, kind: K, picks: &[(Handle, Hint)]) -> std::result::Result<Vec<GeomRef>, String> {
let d = s.doc().map_err(|e| e.to_string())?;
let want = wants(kind, picks.len());
if picks.len() < want.len() {
return Err(format!("{} needs {} object(s)", kind.name(), want.len()));
}
if kind == K::Coincident
&& let [(a, Hint::None), (b, Hint::None)] = picks
{
return closest_points(d, *a, *b).map(|(x, y)| vec![x, y]).ok_or_else(|| "Select two points".into());
}
let ap = aperture(s);
let mut refs: Vec<GeomRef> = Vec::new();
for ((h, hint), w) in picks.iter().zip(want) {
refs.push(resolve(d, *h, *hint, w, ap)?);
}
Ok(refs)
}
/// Add a constraint to the active drawing; the drawing changes only on success.
fn add_constraint(s: &mut Session, c: Constraint) -> Result<u32> {
let keep: Vec<Handle> = c.refs.first().map(|r| r.handle).into_iter().collect();
let prefer_move: Vec<Handle> = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect();
let mut d = s.doc()?.clone();
let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?;
*s.doc_mut()? = d;
Ok(id)
}
fn constraint_json(d: &Drawing, id: u32) -> Value {
match d.constraints.iter().find(|c| c.id == id) {
Some(c) => {
let mut v = json!({ "id": id, "type": c.kind.name(), "refs": c.refs.iter().map(|r| json!({"handle": r.handle.hex(), "sub": r.sub.label()})).collect::<Vec<_>>() });
if c.kind.is_dimensional() {
v["name"] = json!(c.name);
v["expr"] = json!(c.expr);
v["value"] = json!(cs::constraint_value(&mut cs::env(d), c).ok());
}
v
}
None => json!({ "id": id }),
}
}
// ---------------- geometric constraints ----------------
fn kind_from_name(t: &str) -> Option<K> {
Some(match t.trim().trim_start_matches("gc").to_ascii_lowercase().as_str() {
"coincident" => K::Coincident,
"collinear" => K::Collinear,
"concentric" => K::Concentric,
"fix" => K::Fix,
"parallel" => K::Parallel,
"perpendicular" => K::Perpendicular,
"horizontal" => K::Horizontal,
"vertical" => K::Vertical,
"tangent" => K::Tangent,
"smooth" => K::Smooth,
"symmetric" => K::Symmetric,
"equal" => K::Equal,
_ => return None,
})
}
fn run_geomconstraint(s: &mut Session, p: &Value) -> Result<Value> {
let t = str_param(p, "type").ok_or_else(|| bad("geomconstraint", "`type` is required"))?;
let kind = kind_from_name(t).ok_or_else(|| bad("geomconstraint", format!("unknown constraint type `{t}`")))?;
run_gc(s, p, kind)
}
fn run_gc(s: &mut Session, p: &Value, kind: K) -> Result<Value> {
let cmd = "geomconstraint";
let mut picks = Vec::new();
for (hk, pk) in [("h1", "p1"), ("h2", "p2"), ("h3", "p3")] {
let hint = hint_param(cmd, p, pk)?;
match h_param(p, hk) {
Some(h) => picks.push((h, hint)),
// Two points on the same object: {h1, p1, p2}.
None if hint != Hint::None => {
if let Some(first) = picks.first().map(|x: &(Handle, Hint)| x.0) {
picks.push((first, hint));
}
}
None => {}
}
}
if picks.is_empty() {
return Err(bad(cmd, "`h1` is required"));
}
let refs = build_refs(s, kind, &picks).map_err(|m| bad(cmd, m))?;
let id = add_constraint(s, Constraint { id: 0, kind, refs, name: String::new(), expr: String::new() })?;
Ok(constraint_json(s.doc()?, id))
}
struct GcM {
kind: K,
picks: Vec<(Handle, Vec2)>,
two_points: bool,
}
impl GcM {
fn new(kind: K) -> Self {
GcM { kind, picks: Vec::new(), two_points: false }
}
fn needed(&self) -> usize {
match self.kind {
K::Horizontal | K::Vertical => {
if self.two_points {
2
} else {
1
}
}
K::Fix => 1,
K::Symmetric => 3,
_ => 2,
}
}
fn finish(&mut self, s: &mut Session) -> Result<Step> {
let picks: Vec<(Handle, Hint)> = self.picks.iter().map(|(h, p)| (*h, Hint::Pick(*p))).collect();
let r = build_refs(s, self.kind, &picks)
.map_err(EngineError::Other)
.and_then(|refs| add_constraint(s, Constraint { id: 0, kind: self.kind, refs, name: String::new(), expr: String::new() }));
s.set_selection(Vec::new());
match r {
Ok(id) => s.echo(format!("{} constraint #{id} applied.", self.kind.name())),
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
}
impl Interactive for GcM {
fn name(&self) -> &'static str {
"GEOMCONSTRAINT"
}
fn prompt(&self, _s: &Session) -> Prompt {
let i = self.picks.len();
let point_wise = self.kind == K::Coincident || self.two_points;
match (self.kind, i) {
(K::Horizontal | K::Vertical, 0) if !self.two_points => Prompt::new("Select an object", Accept::POINT).kw(&["2Points"]),
(K::Fix, _) => Prompt::new("Select point or object", Accept::POINT),
(K::Symmetric, 2) => Prompt::new("Select symmetry line", Accept::POINT),
(_, 0) if point_wise => Prompt::new("Select first point", Accept::POINT),
(_, _) if point_wise => Prompt::new("Select second point", Accept::POINT),
(_, 0) => Prompt::new("Select first object", Accept::POINT),
_ => Prompt::new("Select second object", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Keyword(k) if k == "2Points" => {
self.two_points = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let space = s.space();
let Some(h) = crate::select::pick(s.doc()?, &space, p, aperture(s)) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
self.picks.push((h, p));
s.set_selection(self.picks.iter().map(|x| x.0).collect());
if self.picks.len() >= self.needed() { self.finish(s) } else { Ok(Step::Continue) }
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
/// GEOMCONSTRAINT: ask for the type, then run that constraint's prompts.
#[derive(Default)]
struct GeomM {
inner: Option<GcM>,
}
const GEOM_KW: &[&str] = &[
"Horizontal",
"Vertical",
"Perpendicular",
"PArallel",
"Tangent",
"SMooth",
"Coincident",
"CONcentric",
"COLlinear",
"Symmetric",
"Equal",
"Fix",
];
impl Interactive for GeomM {
fn name(&self) -> &'static str {
"GEOMCONSTRAINT"
}
fn prompt(&self, s: &Session) -> Prompt {
match &self.inner {
Some(m) => m.prompt(s),
None => Prompt::new("Enter constraint type", Accept::TEXT).kw(GEOM_KW).default("Horizontal"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(m) = &mut self.inner {
return m.input(s, i);
}
let name = match i {
Input::Keyword(k) | Input::Text(k) => k,
Input::Enter => "Horizontal".into(),
_ => return Ok(Step::Continue),
};
let kind = kind_from_name(&name).ok_or_else(|| EngineError::Other("Invalid option keyword.".into()))?;
self.inner = Some(GcM::new(kind));
Ok(Step::Continue)
}
}
// ---------------- dimensional constraints ----------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Dc {
Linear,
Aligned,
Horizontal,
Vertical,
Angular,
Radius,
Diameter,
}
fn dc_from_name(t: &str) -> Option<Dc> {
Some(match t.trim().trim_start_matches("dc").to_ascii_lowercase().as_str() {
"linear" => Dc::Linear,
"aligned" | "distance" => Dc::Aligned,
"horizontal" => Dc::Horizontal,
"vertical" => Dc::Vertical,
"angular" | "angle" => Dc::Angular,
"radius" | "radial" => Dc::Radius,
"diameter" => Dc::Diameter,
_ => return None,
})
}
/// The constraint kind for a DC type; LINEAR picks horizontal or vertical from the geometry.
fn dc_kind(d: &Drawing, dc: Dc, refs: &[GeomRef]) -> K {
match dc {
Dc::Aligned => K::Distance(DistAxis::Aligned),
Dc::Horizontal => K::Distance(DistAxis::Horizontal),
Dc::Vertical => K::Distance(DistAxis::Vertical),
Dc::Angular => K::Angular,
Dc::Radius => K::Radius,
Dc::Diameter => K::Diameter,
Dc::Linear => {
let h = cs::measure(d, K::Distance(DistAxis::Horizontal), refs).unwrap_or(0.0);
let v = cs::measure(d, K::Distance(DistAxis::Vertical), refs).unwrap_or(0.0);
K::Distance(if h >= v { DistAxis::Horizontal } else { DistAxis::Vertical })
}
}
}
fn run_dimconstraint(s: &mut Session, p: &Value) -> Result<Value> {
let t = str_param(p, "type").unwrap_or("aligned");
let dc = dc_from_name(t).ok_or_else(|| bad("dimconstraint", format!("unknown type `{t}`")))?;
run_dc(s, p, dc)
}
fn run_dc(s: &mut Session, p: &Value, dc: Dc) -> Result<Value> {
let cmd = "dimconstraint";
let h1 = h_param(p, "h1").ok_or_else(|| bad(cmd, "`h1` is required"))?;
let (p1, p2) = (hint_param(cmd, p, "p1")?, hint_param(cmd, p, "p2")?);
let h2 = h_param(p, "h2");
let d = s.doc()?;
let ap = aperture(s);
let r = |h, hint, w| resolve(d, h, hint, w, ap).map_err(|m| bad(cmd, m));
let refs = match dc {
Dc::Linear | Dc::Aligned | Dc::Horizontal | Dc::Vertical => {
if h2.is_some() || p2 != Hint::None {
// `p2: "object"` on a line: point-to-line distance (aligned only).
let second_whole = matches!(p2, Hint::Sub(Sub::Whole | Sub::Segment(_)));
let w2 = if second_whole && dc == Dc::Aligned { Want::Line } else { Want::Point };
// Two points of one object default to its start and end.
let p2 = if p2 == Hint::None && h2.is_none_or(|h| h == h1) { Hint::Sub(Sub::End) } else { p2 };
vec![r(h1, p1, Want::Point)?, r(h2.unwrap_or(h1), p2, w2)?]
} else {
vec![r(h1, p1, Want::Line)?]
}
}
Dc::Angular => match h2 {
Some(h2) => vec![r(h1, p1, Want::Line)?, r(h2, p2, Want::Line)?],
None => vec![r(h1, p1, Want::Curve)?],
},
Dc::Radius | Dc::Diameter => vec![r(h1, p1, Want::Curve)?],
};
let kind = dc_kind(d, dc, &refs);
let expr = match (str_param(p, "expr"), p.get("value").and_then(Value::as_f64)) {
(Some(e), _) => e.trim().to_string(),
(None, Some(v)) if v.is_finite() => cs::format_value(v),
_ => String::new(),
};
let name = str_param(p, "name").unwrap_or("").trim().to_string();
let id = add_constraint(s, Constraint { id: 0, kind, refs, name, expr })?;
Ok(constraint_json(s.doc()?, id))
}
struct DcM {
dc: Option<Dc>,
refs: Vec<GeomRef>,
points: bool,
/// Picking done: waiting for the value.
value: Option<(K, f64)>,
}
impl DcM {
fn new(dc: Option<Dc>) -> Self {
DcM { dc, refs: Vec::new(), points: false, value: None }
}
fn picked(&mut self, s: &mut Session) -> Result<Step> {
let d = s.doc()?;
let Some(dc) = self.dc else { return Ok(Step::Continue) };
let kind = dc_kind(d, dc, &self.refs);
match cs::measure(d, kind, &self.refs) {
Ok(v) => {
self.value = Some((kind, v));
Ok(Step::Continue)
}
Err(m) => {
self.refs.clear();
Err(EngineError::Other(m))
}
}
}
fn commit(&mut self, s: &mut Session, text: Option<&str>) -> Result<Step> {
let Some((kind, v)) = self.value else { return Ok(Step::Continue) };
let (name, expr) = match text.map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => match t.split_once('=') {
Some((n, e)) => (n.trim().to_string(), e.trim().to_string()),
None => (String::new(), t.to_string()),
},
None => (String::new(), cs::format_value(v)),
};
let c = Constraint { id: 0, kind, refs: std::mem::take(&mut self.refs), name, expr };
match add_constraint(s, c) {
Ok(id) => {
let d = s.doc()?;
if let Some(c) = d.constraints.iter().find(|c| c.id == id) {
let msg = format!("{} = {}", c.name, c.expr);
s.echo(msg);
}
}
Err(e) => s.echo(e.to_string()),
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
const DC_KW: &[&str] = &["LInear", "Horizontal", "Vertical", "ALigned", "ANgular", "Radius", "Diameter"];
impl Interactive for DcM {
fn name(&self) -> &'static str {
"DIMCONSTRAINT"
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some((_, v)) = self.value {
return Prompt::new("Enter dimension value or [name=expression]", Accept::TEXT).default(cs::format_value(v));
}
let _ = s;
match self.dc {
None => Prompt::new("Select a dimension constraint type", Accept::TEXT).kw(DC_KW).default("ALigned"),
Some(Dc::Radius | Dc::Diameter) => Prompt::new("Select arc or circle", Accept::POINT),
Some(Dc::Angular) if self.refs.is_empty() => Prompt::new("Select first line or arc", Accept::POINT),
Some(Dc::Angular) => Prompt::new("Select second line", Accept::POINT),
Some(_) if self.points && self.refs.is_empty() => Prompt::new("Specify first constraint point", Accept::POINT),
Some(_) if self.points => Prompt::new("Specify second constraint point", Accept::POINT),
Some(_) => Prompt::new("Select object", Accept::POINT).kw(&["Points"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.value.is_some() {
return match i {
Input::Text(t) | Input::Keyword(t) => self.commit(s, Some(&t)),
Input::Enter => self.commit(s, None),
_ => Ok(Step::Continue),
};
}
let Some(dc) = self.dc else {
let name = match i {
Input::Keyword(k) | Input::Text(k) => k,
Input::Enter => "aligned".into(),
_ => return Ok(Step::Continue),
};
self.dc = Some(dc_from_name(&name).ok_or_else(|| EngineError::Other("Invalid option keyword.".into()))?);
return Ok(Step::Continue);
};
match i {
Input::Keyword(k) if k == "Points" => {
self.points = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let space = s.space();
let ap = aperture(s);
let d = s.doc()?;
let Some(h) = crate::select::pick(d, &space, p, ap) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let want = match dc {
Dc::Radius | Dc::Diameter => Want::Curve,
Dc::Angular if self.refs.is_empty() => {
if matches!(d.entity(h).map(|e| &e.kind), Some(EntityKind::Arc(_))) {
Want::Curve
} else {
Want::Line
}
}
Dc::Angular => Want::Line,
_ if self.points => Want::Point,
_ => Want::Line,
};
let r = resolve(d, h, Hint::Pick(p), want, ap).map_err(EngineError::Other)?;
self.refs.push(r);
let done = match dc {
Dc::Radius | Dc::Diameter => true,
Dc::Angular => want == Want::Curve || self.refs.len() >= 2,
_ => !self.points || self.refs.len() >= 2,
};
if done { self.picked(s) } else { Ok(Step::Continue) }
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
// ---------------- selection-based commands ----------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SelOp {
Auto,
Delete,
Bars,
Dims,
}
struct SelM {
op: SelOp,
phase: SelectPhase,
}
impl SelM {
fn new(op: SelOp) -> Self {
SelM { op, phase: SelectPhase::default() }
}
fn apply(&self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
let handles = json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>());
let r = match self.op {
SelOp::Auto => run_autoconstrain(s, &json!({ "handles": handles })),
SelOp::Delete => run_delconstraint(s, &json!({ "handles": handles })),
SelOp::Bars => set_bars(s, false, "show", &hs),
SelOp::Dims => set_bars(s, true, "show", &hs),
};
match r {
Ok(v) => {
if let Some(m) = v.get("message").and_then(Value::as_str) {
s.echo(m.to_string());
}
}
Err(e) => s.echo(e.to_string()),
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
impl Interactive for SelM {
fn name(&self) -> &'static str {
match self.op {
SelOp::Auto => "AUTOCONSTRAIN",
SelOp::Delete => "DELCONSTRAINT",
SelOp::Bars => "CONSTRAINTBAR",
SelOp::Dims => "DCDISPLAY",
}
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.phase = SelectPhase::begin(s);
if self.phase.done {
let hs = self.phase.picked.clone();
return self.apply(s, hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
self.phase.prompt()
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.phase.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.apply(s, hs),
}
}
}
fn run_autoconstrain(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.is_empty() {
return Err(bad("autoconstrain", "select objects (or pass `handles`)"));
}
let set = &s.doc()?.parametric.settings;
let mut o = cs::InferOptions { distance_tolerance: set.distance_tolerance, angle_tolerance: set.angle_tolerance, types: set.auto_types.clone() };
o.distance_tolerance = f64_or(p, "tolerance", o.distance_tolerance);
o.angle_tolerance = f64_or(p, "angleTolerance", o.angle_tolerance);
if let Some(t) = p.get("types").and_then(Value::as_array) {
o.types = t.iter().filter_map(Value::as_str).map(str::to_string).collect();
}
let mut d = s.doc()?.clone();
let ids = cs::autoconstrain(&mut d, &hs, &o);
if !ids.is_empty() {
*s.doc_mut()? = d;
}
Ok(json!({ "added": ids.len(), "ids": ids, "message": format!("{} constraint(s) applied to {} object(s)", ids.len(), hs.len()) }))
}
fn run_delconstraint(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Option<Vec<u32>> =
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect());
let hs = targets(s, p)?;
if ids.is_none() && hs.is_empty() {
return Err(bad("delconstraint", "select objects (or pass `handles` / `ids`)"));
}
let doomed = |c: &Constraint| match &ids {
Some(ids) => ids.contains(&c.id),
None => c.refs.iter().any(|r| hs.contains(&r.handle)),
};
let n = s.doc()?.constraints.iter().filter(|c| doomed(c)).count();
if n > 0 {
s.doc_mut()?.constraints.retain(|c| !doomed(c));
}
Ok(json!({ "removed": n, "message": format!("{n} constraint(s) removed") }))
}
fn run_parameters(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(name) = str_param(p, "delete") {
let d = s.doc()?;
if d.constraints.iter().any(|c| c.name == name) {
return Err(EngineError::Other(format!("`{name}` belongs to a dimensional constraint; delete the constraint instead")));
}
if !d.parametric.parameters.iter().any(|q| q.name == name) {
return Err(EngineError::Other(format!("no parameter `{name}`")));
}
let mut d = d.clone();
d.parametric.parameters.retain(|q| q.name != name);
if let Some(row) = cs::parameter_table(&d).into_iter().find(|r| r.error.is_some()) {
return Err(EngineError::Other(format!("`{name}` is used by `{}`", row.name)));
}
*s.doc_mut()? = d;
} else if let Some(name) = str_param(p, "name") {
let expr = match (str_param(p, "expr"), p.get("value").and_then(Value::as_f64)) {
(Some(e), _) => e.to_string(),
(None, Some(v)) if v.is_finite() => cs::format_value(v),
_ => return Err(bad("parameters", "`expr` or `value` is required")),
};
let mut d = s.doc()?.clone();
cs::set_parameter(&mut d, name.trim(), &expr, &cs::SolveOptions::default()).map_err(err)?;
if let Some(desc) = str_param(p, "description")
&& let Some(q) = d.parametric.parameters.iter_mut().find(|q| q.name == name.trim())
{
q.description = desc.to_string();
}
*s.doc_mut()? = d;
}
let rows = cs::parameter_table(s.doc()?);
let text: Vec<String> =
rows.iter().map(|r| format!("{} = {} ({})", r.name, r.expr, r.value.map(cs::format_value).unwrap_or_else(|| "error".into()))).collect();
let message = if text.is_empty() { "No parameters.".to_string() } else { text.join("; ") };
Ok(json!({ "parameters": rows, "message": message }))
}
/// Show/hide constraint bars (`dims` = false) or dynamic dimensional constraints.
fn set_bars(s: &mut Session, dims: bool, mode: &str, hs: &[Handle]) -> Result<Value> {
let d = s.doc_mut()?;
let set = &mut d.parametric.settings;
let (visible, exceptions) =
if dims { (&mut set.dims_visible, &mut set.dim_exceptions) } else { (&mut set.bars_visible, &mut set.bar_exceptions) };
match mode {
"showall" => {
*visible = true;
exceptions.clear();
}
"hideall" => {
*visible = false;
exceptions.clear();
}
"show" | "hide" => {
// Exceptions are hidden objects when visible, shown objects when hidden.
let add = (mode == "hide") == *visible;
for h in hs {
exceptions.retain(|x| x != h);
if add {
exceptions.push(*h);
}
}
exceptions.truncate(100_000);
}
other => return Err(bad(if dims { "dcdisplay" } else { "constraintbar" }, format!("unknown mode `{other}`"))),
}
let what = if dims { "Dynamic constraints" } else { "Constraint bars" };
Ok(json!({ "visible": *visible, "exceptions": exceptions.iter().map(|h| h.hex()).collect::<Vec<_>>(), "message": format!("{what}: {mode}") }))
}
fn bar_cmd(s: &mut Session, p: &Value, dims: bool) -> Result<Value> {
let mode = str_param(p, "mode").unwrap_or("show").to_ascii_lowercase();
let hs = targets(s, p)?;
if matches!(mode.as_str(), "show" | "hide") && hs.is_empty() {
return Err(bad(if dims { "dcdisplay" } else { "constraintbar" }, "select objects (or pass `handles`)"));
}
set_bars(s, dims, &mode, &hs)
}
fn run_constraintbar(s: &mut Session, p: &Value) -> Result<Value> {
bar_cmd(s, p, false)
}
fn run_dcdisplay(s: &mut Session, p: &Value) -> Result<Value> {
bar_cmd(s, p, true)
}
fn run_constraintsettings(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.parametric.settings.clone();
let mut set = cur.clone();
set.infer = bool_or(p, "infer", set.infer);
set.distance_tolerance = f64_or(p, "distanceTolerance", set.distance_tolerance).clamp(0.0, 1e6);
set.angle_tolerance = f64_or(p, "angleTolerance", set.angle_tolerance).clamp(0.0, 45.0);
set.bars_visible = bool_or(p, "barsVisible", set.bars_visible);
set.dims_visible = bool_or(p, "dimsVisible", set.dims_visible);
if let Some(t) = p.get("barTransparency").and_then(Value::as_f64).filter(|v| v.is_finite()) {
set.bar_transparency = t.clamp(0.0, 90.0) as u8;
}
if let Some(t) = p.get("autoTypes").and_then(Value::as_array) {
set.auto_types = t.iter().filter_map(Value::as_str).filter(|n| kind_from_name(n).is_some()).map(str::to_string).take(32).collect();
}
if set != cur {
s.doc_mut()?.parametric.settings = set.clone();
}
serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))
}
fn run_inspect(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
Ok(json!({
"constraints": d.constraints,
"glyphs": cs::glyphs(d),
"parameters": cs::parameter_table(d),
"settings": d.parametric.settings,
"satisfied": cs::all_satisfied(d),
}))
}
// ---------------- re-solve after edits ----------------
/// Called after every command (programmatic or interactive). When the command changed the
/// drawing and its constraints no longer hold, re-solve them, keeping the geometry the command
/// edited where it was put; constraints whose objects are gone are removed.
pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
if !ok {
return;
}
let (Some(before), Ok(st)) = (before, s.state()) else { return };
if st.doc.constraints.is_empty() || Arc::ptr_eq(before, &st.doc) {
return;
}
let mut d = (*st.doc).clone();
let purged = cs::purge_invalid(&mut d);
let satisfied = cs::all_satisfied(&d);
if purged.is_empty() && satisfied {
return;
}
let mut msg = Vec::new();
if !purged.is_empty() {
msg.push(format!("{} constraint(s) removed with their objects.", purged.len()));
}
if !satisfied {
let mut keep: Vec<Handle> = Vec::new();
for h in d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)) {
let changed = match (before.entity(h), d.entity(h)) {
(Some(a), Some(b)) => !Arc::ptr_eq(a, b) && a != b,
_ => false,
};
if changed && !keep.contains(&h) {
keep.push(h);
}
}
let mut trial = d.clone();
match cs::solve(&mut trial, &cs::SolveOptions { keep, prefer_move: Vec::new() }) {
Ok(_) => d = trial,
Err(e) => msg.push(format!("Constraints could not be satisfied: {e}")),
}
}
if let Ok(st) = s.state_mut() {
st.doc = Arc::new(d);
}
for m in msg {
s.echo(m);
}
}
#[cfg(test)]
#[path = "constraints_tests.rs"]
mod tests;
+192
View File
@@ -0,0 +1,192 @@
use cadcraft_doc::{ConstraintKind, EntityKind, Handle};
use cadcraft_geom::Vec2;
use serde_json::json;
use crate::{Input, Session, command_specs};
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
s.execute("line", &json!({ "points": [a, b] })).unwrap();
s.doc().unwrap().model.iter().last().unwrap().handle.hex()
}
fn ends(s: &Session, h: &str) -> (Vec2, Vec2) {
let h = Handle::parse_hex(h).unwrap();
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
_ => panic!(),
}
}
#[test]
fn json_geometric_and_dimensional_constraints() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.5]);
let b = line(&mut s, [10.2, 0.3], [10.5, 6.0]);
s.execute("gchorizontal", &json!({ "h1": a })).unwrap();
s.execute("gccoincident", &json!({ "h1": a, "h2": b })).unwrap(); // closest end points
s.execute("geomconstraint", &json!({ "type": "perpendicular", "h1": a, "h2": b })).unwrap();
let r = s.execute("dcaligned", &json!({ "h1": a, "expr": "8" })).unwrap();
assert_eq!(r["name"], "d1");
s.execute("dimconstraint", &json!({ "type": "aligned", "h1": b, "expr": "d1/2" })).unwrap();
let ((a0, a1), (b0, b1)) = (ends(&s, &a), ends(&s, &b));
assert!((a0.y - a1.y).abs() < 1e-6);
assert!(a1.near(b0, 1e-6));
assert!((a0.dist(a1) - 8.0).abs() < 1e-6 && (b0.dist(b1) - 4.0).abs() < 1e-6);
// PARAMETERS: change d1, geometry follows; undo restores.
let r = s.execute("parameters", &json!({ "name": "d1", "expr": "12" })).unwrap();
assert!(r["message"].as_str().unwrap().contains("d1 = 12"));
let (b0, b1) = ends(&s, &b);
assert!((b0.dist(b1) - 6.0).abs() < 1e-6);
s.undo().unwrap();
let (b0, b1) = ends(&s, &b);
assert!((b0.dist(b1) - 4.0).abs() < 1e-6);
// User parameter.
s.execute("parameters", &json!({ "name": "len", "expr": "20" })).unwrap();
s.execute("parameters", &json!({ "name": "d1", "expr": "len/2" })).unwrap();
assert!((ends(&s, &a).0.dist(ends(&s, &a).1) - 10.0).abs() < 1e-6);
assert!(s.execute("parameters", &json!({ "delete": "len" })).is_err(), "still used");
assert!(s.execute("parameters", &json!({ "delete": "d1" })).is_err(), "dimensional");
// Conflict is reported and changes nothing.
let before = s.doc().unwrap().clone();
let e = s.execute("gcvertical", &json!({ "h1": a })).unwrap_err().to_string();
assert!(e.contains("conflict") || e.contains("over-constrain"), "{e}");
assert_eq!(*s.doc().unwrap(), before);
let inspect = s.execute("constraints.inspect", &json!({})).unwrap();
assert_eq!(inspect["constraints"].as_array().unwrap().len(), 5);
assert_eq!(inspect["satisfied"], true);
assert_eq!(inspect["glyphs"][0]["kind"], "Horizontal");
}
#[test]
fn edits_are_resolved_and_erase_purges() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, [10.0, 0.0], [10.0, 5.0]);
s.execute("gccoincident", &json!({ "h1": a, "p1": "end", "h2": b, "p2": "start" })).unwrap();
s.execute("gcfix", &json!({ "h1": a })).unwrap();
// Move line b: the coincidence pulls its start back onto a's (fixed) end.
s.execute("move", &json!({ "handles": [b], "from": [0, 0], "to": [3, 2] })).unwrap();
let ((_, a1), (b0, _)) = (ends(&s, &a), ends(&s, &b));
assert!(a1.near(Vec2::new(10.0, 0.0), 1e-9), "{a1:?}");
assert!(b0.near(a1, 1e-6), "{b0:?}");
// Erasing a constrained object removes its constraints.
s.execute("erase", &json!({ "handles": [b] })).unwrap();
let kinds: Vec<ConstraintKind> = s.doc().unwrap().constraints.iter().map(|c| c.kind).collect();
assert_eq!(kinds, vec![ConstraintKind::Fix]);
s.execute("delconstraint", &json!({ "handles": [a] })).unwrap();
assert!(s.doc().unwrap().constraints.is_empty());
}
#[test]
fn interactive_prompts() {
let mut s = Session::new();
s.viewport_px = (1000.0, 1000.0);
let a = line(&mut s, [0.0, 0.0], [8.0, 0.4]);
let b = line(&mut s, [0.0, 3.0], [8.0, 3.9]);
s.cmdline("gchorizontal").unwrap();
assert!(s.prompt_text().contains("Select an object"));
s.input(Input::Point(Vec2::new(4.0, 0.2))).unwrap();
assert!(s.running.is_none());
s.cmdline("geomconstraint pa").unwrap();
assert!(s.prompt_text().contains("Select first object"), "{}", s.prompt_text());
s.input(Input::Point(Vec2::new(4.0, 0.2))).unwrap();
s.input(Input::Point(Vec2::new(4.0, 3.45))).unwrap();
assert!(s.running.is_none());
let ((a0, a1), (b0, b1)) = (ends(&s, &a), ends(&s, &b));
assert!((a0.y - a1.y).abs() < 1e-6 && (b0.y - b1.y).abs() < 1e-6);
// DIMCONSTRAINT → aligned → pick → value with a name.
s.cmdline("dimconstraint al").unwrap();
s.input(Input::Point(Vec2::new(4.0, a0.y))).unwrap();
assert!(s.prompt_text().contains("dimension value"), "{}", s.prompt_text());
s.cmdline("width=5").unwrap();
assert!(s.running.is_none());
let (a0, a1) = ends(&s, &a);
assert!((a0.dist(a1) - 5.0).abs() < 1e-6);
assert_eq!(s.doc().unwrap().constraints.last().unwrap().name, "width");
// One undo step per interactive constraint.
let n = s.doc().unwrap().constraints.len();
s.undo().unwrap();
assert_eq!(s.doc().unwrap().constraints.len(), n - 1);
// Coincident by picking near end points.
let c = line(&mut s, [20.0, 0.0], [30.0, 0.0]);
let e = line(&mut s, [30.3, 0.2], [30.0, 9.0]);
s.cmdline("gccoincident").unwrap();
s.input(Input::Point(Vec2::new(29.98, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(30.28, 0.3))).unwrap();
assert!(ends(&s, &c).1.near(ends(&s, &e).0, 1e-6));
// AUTOCONSTRAIN with a pickfirst selection.
let f = line(&mut s, [40.0, 0.0], [50.0, 0.01]);
s.set_selection(vec![Handle::parse_hex(&f).unwrap()]);
s.cmdline("autoconstrain").unwrap();
assert!(s.running.is_none());
let (f0, f1) = ends(&s, &f);
assert!((f0.y - f1.y).abs() < 1e-9);
}
#[test]
fn bars_settings_and_autoconstrain_json() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("constraintbar.hideall", &json!({})).unwrap();
let r = s.execute("constraintbar", &json!({ "mode": "show", "handles": [a] })).unwrap();
assert_eq!(r["visible"], false);
assert_eq!(r["exceptions"][0], a);
s.execute("dcdisplay.showall", &json!({})).unwrap();
assert!(s.execute("constraintbar", &json!({ "mode": "bogus", "handles": [a] })).is_err());
let r = s.execute("constraintsettings", &json!({ "angleTolerance": 2.5, "autoTypes": ["Horizontal", "nope"], "infer": true })).unwrap();
assert_eq!(r["angleTolerance"], 2.5);
assert_eq!(r["autoTypes"], json!(["Horizontal"]));
let b = line(&mut s, [0.0, 5.0], [0.01, 9.0]);
let r = s.execute("autoconstrain", &json!({ "handles": [a, b] })).unwrap();
// Only Horizontal is enabled: a is horizontal; b (near vertical) is left alone.
assert_eq!(r["added"], 1);
assert_eq!(s.doc().unwrap().constraints[0].kind, ConstraintKind::Horizontal);
}
#[test]
fn hostile_constraint_params_never_panic() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let c = {
s.execute("circle", &json!({ "center": [0, 5], "radius": 2 })).unwrap();
s.doc().unwrap().model.iter().last().unwrap().handle.hex()
};
let hostile = [
json!({ "h1": a, "h2": a }),
json!({ "h1": a, "p1": "v999999", "h2": c, "p2": [1e308, -1e308] }),
json!({ "h1": a, "p1": "zzz" }),
json!({ "h1": c, "h2": c, "h3": c }),
json!({ "h1": "ffffffffffffffff", "h2": 1 }),
json!({ "h1": a, "expr": "((((" }),
json!({ "h1": a, "expr": "1/0" }),
json!({ "h1": a, "value": -5 }),
json!({ "h1": c, "expr": "1e309" }),
json!({ "h1": a, "name": "bad name", "expr": "3" }),
json!({ "type": "nope", "h1": a }),
json!({ "name": "x", "expr": "x+1" }),
json!({ "name": "", "expr": "1" }),
json!({ "delete": "nothing" }),
json!({ "ids": [1, -1, "a", 1e20] }),
json!({ "mode": "show" }),
json!({ "tolerance": -1, "angleTolerance": 1e308, "handles": [a, c] }),
];
let ids: Vec<&str> = command_specs()
.iter()
.map(|c| c.id)
.filter(|id| id.starts_with("gc") || id.starts_with("dc") || id.contains("constrain") || *id == "parameters" || id.starts_with("constraint"))
.collect();
assert!(ids.len() >= 25, "{ids:?}");
for id in ids {
for p in &hostile {
let _ = s.execute(id, p);
}
// Interactive: start, junk inputs, cancel.
s.start(id).unwrap();
for i in
[Input::Point(Vec2::new(5.0, 0.0)), Input::Text("x".into()), Input::Point(Vec2::new(0.0, 7.0)), Input::Enter, Input::Text("1/0".into())]
{
let _ = s.input(i);
}
s.cancel();
assert!(s.running.is_none());
}
}
+4
View File
@@ -3,6 +3,7 @@
mod annotate;
mod blocks;
pub mod constraints;
mod draw;
mod draw2;
mod edit;
@@ -16,6 +17,7 @@ mod modify;
mod modify2;
mod props;
mod settings;
mod table;
mod utility;
mod view;
@@ -161,6 +163,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(inquiry::specs());
v.extend(settings::specs());
v.extend(utility::specs());
v.extend(table::specs());
v.extend(constraints::specs());
v
})
}
+387 -28
View File
@@ -19,8 +19,33 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}"),
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
CommandSpec::new("style", "Text Style...", run_style).menu(&["Format", "Text Style..."]).alias(&["st"]).params("{name, font?, height?, widthFactor?, oblique?, current?}"),
CommandSpec::new("dimstyle", "Dimension Style...", run_dimstyle).menu(&["Format", "Dimension Style..."]).alias(&["d", "dst", "ddim"]).params("{name, current?, <style fields>}"),
CommandSpec::new("style", "Text Style...", run_style)
.menu(&["Format", "Text Style..."])
.alias(&["st"])
.params("{name, font?, bigFont?, height?, widthFactor?, oblique? (degrees), backwards?, upsideDown?, vertical?, annotative?, current?} → styles"),
CommandSpec::new("style.list", "List Text Styles", run_style_list).params("{} → text styles").noundo(),
CommandSpec::new("style.rename", "Rename Text Style", run_style_rename).params("{from, to}"),
CommandSpec::new("style.delete", "Delete Text Style", run_style_delete).params("{name} (not Standard, the current style or one in use)"),
CommandSpec::new("style.current", "Set Current Text Style", run_style_current).params("{name}"),
CommandSpec::new("dimstyle", "Dimension Style...", run_dimstyle)
.menu(&["Format", "Dimension Style..."])
.alias(&["d", "dst", "ddim"])
.params("{name, current?, <style fields or DIM* variables, e.g. arrowSize / DIMASZ, DIMTSZ, DIMBLK, DIMTAD, DIMLUNIT…>} → styles"),
CommandSpec::new("dimstyle.dimension", "Dimension Style...", run_dimstyle).menu(&["Dimension", "Dimension Style..."]).params("same as `dimstyle`"),
CommandSpec::new("dimstyle.list", "List Dimension Styles", run_dimstyle_list).params("{name?} → styles (with all variables for `name`)").noundo(),
CommandSpec::new("dimstyle.rename", "Rename Dimension Style", run_dimstyle_rename).params("{from, to}"),
CommandSpec::new("dimstyle.delete", "Delete Dimension Style", run_dimstyle_delete).params("{name} (not Standard, the current style or one in use)"),
CommandSpec::new("dimstyle.current", "Set Current Dimension Style", run_dimstyle_current).params("{name}"),
CommandSpec::new("dimstyle.override", "Dimension Style Override", dim_override)
.params("{handles?, <style fields or DIM* variables: values>, clear?: bool} (per-dimension overrides)"),
CommandSpec::new("tablestyle", "Table Style...", run_tablestyle)
.menu(&["Format", "Table Style..."])
.alias(&["ts"])
.params("{name, textHeight?, margin?, title?, header?, current?} → styles"),
CommandSpec::new("mleaderstyle", "Multileader Style...", run_mleaderstyle)
.menu(&["Format", "Multileader Style..."])
.alias(&["mls"])
.params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, current?} → styles"),
CommandSpec::new("ddptype", "Point Style...", run_ptype).menu(&["Format", "Point Style..."]).params("{pdmode, pdsize}"),
CommandSpec::new("rename", "Rename...", run_rename).menu(&["Format", "Rename..."]).params("{table: layer|linetype|style|dimstyle|block, from, to}"),
]
@@ -358,8 +383,33 @@ fn run_limits(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn style_json(st: &cadcraft_doc::TextStyle, current: &str) -> Value {
json!({
"name": st.name,
"font": st.font,
"bigFont": st.big_font,
"height": st.height,
"widthFactor": st.width_factor,
"oblique": st.oblique.to_degrees(),
"backwards": st.backwards,
"upsideDown": st.upside_down,
"vertical": st.vertical,
"annotative": st.annotative,
"current": st.name.eq_ignore_ascii_case(current),
"trueType": cadcraft_fonts::TextFont::resolve(&st.font).is_outline(),
})
}
fn styles_json(d: &cadcraft_doc::Drawing) -> Value {
let cur = d.header.str("TEXTSTYLE", "Standard");
json!({ "current": cur, "styles": d.text_styles.iter().map(|t| style_json(t, &cur)).collect::<Vec<_>>() })
}
fn run_style(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style", "`name` is required"))?.to_string();
let name = str_param(p, "name").ok_or_else(|| bad("style", "`name` is required"))?.trim().to_string();
if name.is_empty() || name.len() > 255 {
return Err(bad("style", "bad style name"));
}
let d = s.doc_mut()?;
let idx = match d.text_styles.iter().position(|t| t.name.eq_ignore_ascii_case(&name)) {
Some(i) => i,
@@ -368,28 +418,150 @@ fn run_style(s: &mut Session, p: &Value) -> Result<Value> {
d.text_styles.len() - 1
}
};
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(st) = d.text_styles.get_mut(idx) {
if let Some(f) = str_param(p, "font") {
if let Some(f) = str_param(p, "font").map(str::trim).filter(|f| !f.is_empty()) {
st.font = f.to_string();
}
if let Some(h) = p.get("height").and_then(Value::as_f64).filter(|h| *h >= 0.0) {
if let Some(f) = str_param(p, "bigFont") {
st.big_font = f.to_string();
}
if let Some(h) = num("height").filter(|h| *h >= 0.0) {
st.height = h;
}
if let Some(w) = p.get("widthFactor").and_then(Value::as_f64).filter(|w| *w > 0.0) {
if let Some(w) = num("widthFactor").filter(|w| *w > 0.0 && *w <= 100.0) {
st.width_factor = w;
}
if let Some(o) = p.get("oblique").and_then(Value::as_f64) {
if let Some(o) = num("oblique").filter(|o| o.abs() <= 85.0) {
st.oblique = o.to_radians();
}
for (k, slot) in [
("backwards", &mut st.backwards),
("upsideDown", &mut st.upside_down),
("vertical", &mut st.vertical),
("annotative", &mut st.annotative),
] {
if let Some(b) = p.get(k).and_then(Value::as_bool) {
*slot = b;
}
}
}
if bool_or(p, "current", true) {
d.header.set_str("TEXTSTYLE", &name);
}
Ok(styles_json(d))
}
fn run_style_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(styles_json(s.doc()?))
}
fn run_style_current(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.current", "`name` is required"))?;
let d = s.doc_mut()?;
let n = d.text_style(name).map(|t| t.name.clone()).ok_or_else(|| bad("style.current", format!("no text style `{name}`")))?;
d.header.set_str("TEXTSTYLE", &n);
ok()
}
/// Text style names used by entities (in model, paper spaces and blocks) and dimension styles.
fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec<String> {
let mut out: Vec<String> = d.dim_styles.iter().map(|s| s.text_style.clone()).collect();
out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone()));
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for st in stores {
for e in st.iter() {
match &e.kind {
EntityKind::Text(t) => out.push(t.style.clone()),
EntityKind::MText(t) => out.push(t.style.clone()),
EntityKind::AttDef(a) => out.push(a.text.style.clone()),
EntityKind::Insert(i) => out.extend(i.attribs.iter().map(|a| a.text.style.clone())),
EntityKind::MLeader(m) => out.extend(m.text.iter().map(|t| t.style.clone())),
_ => {}
}
}
}
out
}
fn run_style_rename(s: &mut Session, p: &Value) -> Result<Value> {
let from = str_param(p, "from").ok_or_else(|| bad("style.rename", "`from` is required"))?.to_string();
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("style.rename", "`to` is required"))?.to_string();
let d = s.doc_mut()?;
if from.eq_ignore_ascii_case("Standard") {
return Err(bad("style.rename", "the Standard style cannot be renamed"));
}
if d.text_style(&to).is_some() && !to.eq_ignore_ascii_case(&from) {
return Err(bad("style.rename", format!("a text style `{to}` already exists")));
}
let st = d.text_styles.iter_mut().find(|t| t.name.eq_ignore_ascii_case(&from)).ok_or_else(|| bad("style.rename", "no such style"))?;
st.name = to.clone();
// References follow the new name.
let fix = |n: &mut String| {
if n.eq_ignore_ascii_case(&from) {
*n = to.clone();
}
};
if d.header.str("TEXTSTYLE", "Standard").eq_ignore_ascii_case(&from) {
d.header.set_str("TEXTSTYLE", &to);
}
d.dim_styles.iter_mut().for_each(|x| fix(&mut x.text_style));
d.mleader_styles.iter_mut().for_each(|x| fix(&mut x.text_style));
for space in
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone())).collect::<Vec<_>>())
{
let hs: Vec<cadcraft_doc::Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
for h in hs {
let uses = d.entity(h).is_some_and(|e| match &e.kind {
EntityKind::Text(t) => t.style.eq_ignore_ascii_case(&from),
EntityKind::MText(t) => t.style.eq_ignore_ascii_case(&from),
EntityKind::AttDef(a) => a.text.style.eq_ignore_ascii_case(&from),
EntityKind::Insert(i) => i.attribs.iter().any(|a| a.text.style.eq_ignore_ascii_case(&from)),
_ => false,
});
if uses {
d.modify_entity(h, |e| match &mut e.kind {
EntityKind::Text(t) => fix(&mut t.style),
EntityKind::MText(t) => fix(&mut t.style),
EntityKind::AttDef(a) => fix(&mut a.text.style),
EntityKind::Insert(i) => i.attribs.iter_mut().for_each(|a| fix(&mut a.text.style)),
_ => {}
})?;
}
}
}
Ok(styles_json(d))
}
fn run_style_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.delete", "`name` is required"))?.to_string();
let d = s.doc_mut()?;
if name.eq_ignore_ascii_case("Standard") {
return Err(bad("style.delete", "the Standard style cannot be deleted"));
}
if d.header.str("TEXTSTYLE", "Standard").eq_ignore_ascii_case(&name) {
return Err(bad("style.delete", "the current text style cannot be deleted"));
}
if text_styles_in_use(d).iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return Err(bad("style.delete", format!("text style `{name}` is in use")));
}
let before = d.text_styles.len();
d.text_styles.retain(|t| !t.name.eq_ignore_ascii_case(&name));
if d.text_styles.len() == before {
return Err(bad("style.delete", format!("no text style `{name}`")));
}
Ok(styles_json(d))
}
fn dimstyles_json(d: &cadcraft_doc::Drawing) -> Value {
json!({ "current": d.header.str("DIMSTYLE", "Standard"), "styles": d.dim_styles.iter().map(|s| s.name.clone()).collect::<Vec<_>>() })
}
fn run_dimstyle(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("dimstyle", "`name` is required"))?.to_string();
let name = str_param(p, "name").ok_or_else(|| bad("dimstyle", "`name` is required"))?.trim().to_string();
if name.is_empty() || name.len() > 255 {
return Err(bad("dimstyle", "bad style name"));
}
let d = s.doc_mut()?;
let base = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default();
let idx = match d.dim_styles.iter().position(|t| t.name.eq_ignore_ascii_case(&name)) {
@@ -399,24 +571,215 @@ fn run_dimstyle(s: &mut Session, p: &Value) -> Result<Value> {
d.dim_styles.len() - 1
}
};
if let Some(st) = d.dim_styles.get_mut(idx) {
// Merge any provided fields through serde.
let mut cur = serde_json::to_value(&*st).unwrap_or(Value::Null);
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
for (k, v) in src {
if k != "name" && k != "current" && o.contains_key(k) {
o.insert(k.clone(), v.clone());
}
}
}
if let Ok(ns) = serde_json::from_value::<cadcraft_doc::DimStyle>(cur) {
*st = ns;
}
let mut rejected = Vec::new();
if let (Some(st), Some(src)) = (d.dim_styles.get_mut(idx), p.as_object()) {
let fields: serde_json::Map<String, Value> =
src.iter().filter(|(k, _)| !matches!(k.as_str(), "name" | "current")).map(|(k, v)| (k.clone(), v.clone())).collect();
rejected = st.apply_fields(&fields);
}
if bool_or(p, "current", true) {
d.header.set_str("DIMSTYLE", &name);
}
Ok(json!({ "styles": d.dim_styles.iter().map(|s| s.name.clone()).collect::<Vec<_>>() }))
let mut out = dimstyles_json(d);
if !rejected.is_empty()
&& let Some(o) = out.as_object_mut()
{
o.insert("ignored".into(), json!(rejected));
}
Ok(out)
}
fn run_dimstyle_list(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc()?;
let mut out = dimstyles_json(d);
if let Some(n) = str_param(p, "name") {
let st = d.dim_style(n).ok_or_else(|| bad("dimstyle.list", format!("no dimension style `{n}`")))?;
let vars: serde_json::Map<String, Value> = {
let v = serde_json::to_value(st).unwrap_or(Value::Null);
cadcraft_doc::DIMVARS.iter().filter_map(|(var, field)| v.get(*field).map(|x| ((*var).to_string(), x.clone()))).collect()
};
if let Some(o) = out.as_object_mut() {
o.insert("style".into(), serde_json::to_value(st).unwrap_or(Value::Null));
o.insert("variables".into(), Value::Object(vars));
}
}
Ok(out)
}
fn run_dimstyle_current(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("dimstyle.current", "`name` is required"))?;
let d = s.doc_mut()?;
let n = d.dim_style(name).map(|t| t.name.clone()).ok_or_else(|| bad("dimstyle.current", format!("no dimension style `{name}`")))?;
d.header.set_str("DIMSTYLE", &n);
Ok(dimstyles_json(d))
}
fn dims_using(d: &cadcraft_doc::Drawing, name: &str) -> Vec<(cadcraft_doc::Space, cadcraft_doc::Handle)> {
let mut out = Vec::new();
for space in std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))) {
if let Some(st) = d.space(&space) {
for e in st.iter() {
let uses = match &e.kind {
EntityKind::Dimension(dm) => dm.style.eq_ignore_ascii_case(name),
EntityKind::Leader(l) => l.style.eq_ignore_ascii_case(name),
_ => false,
};
if uses {
out.push((space.clone(), e.handle));
}
}
}
}
out
}
fn run_dimstyle_rename(s: &mut Session, p: &Value) -> Result<Value> {
let from = str_param(p, "from").ok_or_else(|| bad("dimstyle.rename", "`from` is required"))?.to_string();
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("dimstyle.rename", "`to` is required"))?.to_string();
let d = s.doc_mut()?;
if from.eq_ignore_ascii_case("Standard") {
return Err(bad("dimstyle.rename", "the Standard style cannot be renamed"));
}
if d.dim_style(&to).is_some() && !to.eq_ignore_ascii_case(&from) {
return Err(bad("dimstyle.rename", format!("a dimension style `{to}` already exists")));
}
let st = d.dim_styles.iter_mut().find(|t| t.name.eq_ignore_ascii_case(&from)).ok_or_else(|| bad("dimstyle.rename", "no such dimension style"))?;
st.name = to.clone();
if d.header.str("DIMSTYLE", "Standard").eq_ignore_ascii_case(&from) {
d.header.set_str("DIMSTYLE", &to);
}
for (_, h) in dims_using(d, &from) {
d.modify_entity(h, |e| match &mut e.kind {
EntityKind::Dimension(dm) => dm.style = to.clone(),
EntityKind::Leader(l) => l.style = to.clone(),
_ => {}
})?;
}
Ok(dimstyles_json(d))
}
fn run_dimstyle_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("dimstyle.delete", "`name` is required"))?.to_string();
let d = s.doc_mut()?;
if name.eq_ignore_ascii_case("Standard") {
return Err(bad("dimstyle.delete", "the Standard style cannot be deleted"));
}
if d.header.str("DIMSTYLE", "Standard").eq_ignore_ascii_case(&name) {
return Err(bad("dimstyle.delete", "the current dimension style cannot be deleted"));
}
if !dims_using(d, &name).is_empty() {
return Err(bad("dimstyle.delete", format!("dimension style `{name}` is in use")));
}
let before = d.dim_styles.len();
d.dim_styles.retain(|t| !t.name.eq_ignore_ascii_case(&name));
if d.dim_styles.len() == before {
return Err(bad("dimstyle.delete", format!("no dimension style `{name}`")));
}
Ok(dimstyles_json(d))
}
/// Per-dimension style overrides (DIMOVERRIDE): merge fields, or `clear` them.
pub(super) fn dim_override(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let clear = bool_or(p, "clear", false);
let fields: serde_json::Map<String, Value> = p
.as_object()
.map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "handles" | "handle" | "clear")).map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
// Validate against a scratch style; store canonical field names.
let mut scratch = cadcraft_doc::DimStyle::default();
let rejected = scratch.apply_fields(&fields);
let good: serde_json::Map<String, Value> = fields
.iter()
.filter(|(k, _)| !rejected.contains(k))
.filter_map(|(k, v)| cadcraft_doc::DimStyle::field_name(k).map(|f| (f.to_string(), v.clone())))
.collect();
if !clear && good.is_empty() {
return Err(bad("dimoverride", format!("no valid dimension variables given (ignored: {})", rejected.join(", "))));
}
let d = s.doc_mut()?;
let mut n = 0;
for h in &hs {
d.modify_entity(*h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
if clear {
dm.overrides.clear();
}
for (k, v) in &good {
dm.overrides.insert(k.clone(), v.clone());
}
dm.block = None;
n += 1;
}
})?;
}
Ok(json!({ "changed": n, "ignored": rejected }))
}
fn run_tablestyle(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut()?;
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let idx = match d.table_styles.iter().position(|t| t.name.eq_ignore_ascii_case(name)) {
Some(i) => i,
None => {
d.table_styles.push(cadcraft_doc::TableStyle { name: name.to_string(), ..Default::default() });
d.table_styles.len() - 1
}
};
if let Some(st) = d.table_styles.get_mut(idx) {
if let Some(h) = p.get("textHeight").and_then(Value::as_f64).filter(|h| h.is_finite() && *h > 0.0) {
st.text_height = h;
}
if let Some(m) = p.get("margin").and_then(Value::as_f64).filter(|h| h.is_finite() && *h >= 0.0) {
st.margin = m;
}
if let Some(b) = p.get("title").and_then(Value::as_bool) {
st.title = b;
}
if let Some(b) = p.get("header").and_then(Value::as_bool) {
st.header = b;
}
}
if bool_or(p, "current", true) {
d.header.set_str("CTABLESTYLE", name);
}
}
Ok(json!({ "current": d.header.str("CTABLESTYLE", "Standard"), "styles": serde_json::to_value(&d.table_styles).unwrap_or(Value::Null) }))
}
fn run_mleaderstyle(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut()?;
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let idx = match d.mleader_styles.iter().position(|t| t.name.eq_ignore_ascii_case(name)) {
Some(i) => i,
None => {
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: name.to_string(), ..Default::default() });
d.mleader_styles.len() - 1
}
};
if let Some(st) = d.mleader_styles.get_mut(idx) {
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|h| h.is_finite() && *h >= 0.0);
if let Some(v) = num("arrowSize") {
st.arrow_size = v;
}
if let Some(v) = num("textHeight").filter(|v| *v > 0.0) {
st.text_height = v;
}
if let Some(v) = num("landingGap") {
st.landing_gap = v;
}
if let Some(v) = num("dogleg") {
st.dogleg = v;
}
if let Some(t) = str_param(p, "textStyle") {
st.text_style = t.to_string();
}
}
if bool_or(p, "current", true) {
d.header.set_str("CMLEADERSTYLE", name);
}
}
Ok(json!({ "current": d.header.str("CMLEADERSTYLE", "Standard"), "styles": serde_json::to_value(&d.mleader_styles).unwrap_or(Value::Null) }))
}
fn run_ptype(s: &mut Session, p: &Value) -> Result<Value> {
@@ -439,14 +802,10 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
s.execute("layer.set", &json!({ "name": from, "newName": to }))?;
}
"style" => {
let d = s.doc_mut()?;
let st = d.text_styles.iter_mut().find(|t| t.name.eq_ignore_ascii_case(&from)).ok_or_else(|| bad("rename", "no such style"))?;
st.name = to;
run_style_rename(s, &json!({ "from": from, "to": to }))?;
}
"dimstyle" => {
let d = s.doc_mut()?;
let st = d.dim_styles.iter_mut().find(|t| t.name.eq_ignore_ascii_case(&from)).ok_or_else(|| bad("rename", "no such dimension style"))?;
st.name = to;
run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?;
}
"linetype" => {
let d = s.doc_mut()?;
+456
View File
@@ -0,0 +1,456 @@
//! TABLE: create tables and edit cells, rows, columns and merges.
use cadcraft_doc::{EntityKind, Handle, Table, TableCell};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::helpers::v3;
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Caps for hostile parameters.
const MAX_ROWS: usize = 2000;
const MAX_COLS: usize = 200;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("table", "Table...", run_table)
.menu(&["Draw", "Table..."])
.alias(&["tb"])
.params("{at: [x,y], rows (data rows), cols, rowHeight?, colWidth?, cells?: [[text]], title?: text, header?: [text]}")
.interactive(|s| Ok(Box::new(TableM::new(s)))),
CommandSpec::new("table.set", "Set Table Cell", run_set).params("{handle, row, col, text}"),
CommandSpec::new("table.insertrow", "Insert Row", run_insert_row).params("{handle, row? (insert before; default: append), height?}"),
CommandSpec::new("table.deleterow", "Delete Row", run_delete_row).params("{handle, row}"),
CommandSpec::new("table.insertcol", "Insert Column", run_insert_col).params("{handle, col? (insert before; default: append), width?}"),
CommandSpec::new("table.deletecol", "Delete Column", run_delete_col).params("{handle, col}"),
CommandSpec::new("table.merge", "Merge Cells", run_merge).params("{handle, row, col, rows, cols}"),
CommandSpec::new("table.unmerge", "Unmerge Cells", run_unmerge).params("{handle, row, col}"),
]
}
fn usize_param(p: &Value, key: &str) -> Option<usize> {
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
}
fn style_of(s: &Session) -> cadcraft_doc::TableStyle {
let Ok(d) = s.doc() else { return Default::default() };
let cur = d.header.str("CTABLESTYLE", "Standard");
d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default()
}
/// Build a table entity. `rows` data rows; a title row and a header row are added when given.
#[allow(clippy::too_many_arguments)]
pub fn make_table(
st: &cadcraft_doc::TableStyle,
at: Vec2,
rows: usize,
cols: usize,
row_h: Option<f64>,
col_w: Option<f64>,
title: Option<&str>,
header: Option<Vec<String>>,
cells: &[Vec<String>],
) -> Table {
let cols = cols.clamp(1, MAX_COLS);
let rows = rows.clamp(if title.is_some() || header.is_some() { 0 } else { 1 }, MAX_ROWS);
let th = st.text_height;
let data_h = row_h.unwrap_or(th * 5.0 / 3.0 + 2.0 * st.margin);
let mut t = Table {
insert: v3(at),
col_widths: vec![col_w.unwrap_or(2.5); cols],
row_heights: Vec::new(),
cells: Vec::new(),
style: st.name.clone(),
text_height: th,
title: title.is_some(),
header: header.is_some(),
};
let empty_row = || vec![TableCell { text: String::new(), merged: None }; cols];
if let Some(ti) = title {
let mut r = empty_row();
if let Some(c) = r.first_mut() {
c.text = ti.to_string();
c.merged = Some((1, cols as u32));
}
t.cells.push(r);
t.row_heights.push(th * 1.4 * 5.0 / 3.0 + 2.0 * st.margin);
}
if let Some(h) = header {
let mut r = empty_row();
for (c, txt) in r.iter_mut().zip(h) {
c.text = txt;
}
t.cells.push(r);
t.row_heights.push(data_h);
}
for i in 0..rows {
let mut r = empty_row();
if let Some(src) = cells.get(i) {
for (c, txt) in r.iter_mut().zip(src) {
c.text = txt.clone();
}
}
t.cells.push(r);
t.row_heights.push(data_h);
}
t
}
fn run_table(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("table", p, "at")?;
let cells: Vec<Vec<String>> = p
.get("cells")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.take(MAX_ROWS)
.map(|r| {
r.as_array()
.map(|a| a.iter().take(MAX_COLS).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).collect())
.unwrap_or_default()
})
.collect()
})
.unwrap_or_default();
let rows = usize_param(p, "rows").unwrap_or(cells.len().max(1));
let cols = usize_param(p, "cols").unwrap_or_else(|| cells.iter().map(Vec::len).max().unwrap_or(1).max(1));
if rows > MAX_ROWS || cols > MAX_COLS || cols == 0 {
return Err(bad("table", format!("rows must be ≤ {MAX_ROWS} and columns 1..={MAX_COLS}")));
}
let pos = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let header: Option<Vec<String>> =
p.get("header").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect());
let st = style_of(s);
let t = make_table(&st, at, rows, cols, pos("rowHeight"), pos("colWidth"), str_param(p, "title"), header, &cells);
let h = s.add_entity(EntityKind::Table(t))?;
Ok(json!({ "handle": h.hex() }))
}
/// Modify the table `handle` with `f`.
fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result<Value> {
let h = p
.get("handle")
.and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
.or_else(|| {
s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))))
})
.ok_or_else(|| bad(id, "`handle` of a table is required"))?;
let mut t = match s.doc()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::Table(t)) => t.clone(),
_ => return Err(bad(id, "object is not a table")),
};
normalize(&mut t);
f(&mut t).map_err(|m| bad(id, m))?;
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::Table(t))?;
Ok(json!({ "handle": h.hex() }))
}
/// Make the cell grid rows × cols.
fn normalize(t: &mut Table) {
let rows = t.row_heights.len().min(MAX_ROWS);
let cols = t.col_widths.len().min(MAX_COLS);
t.row_heights.truncate(rows);
t.col_widths.truncate(cols);
t.cells.resize_with(rows, Vec::new);
for r in &mut t.cells {
r.resize_with(cols, || TableCell { text: String::new(), merged: None });
}
}
/// Merge anchors: (row, col, rows, cols).
fn anchors(t: &Table) -> Vec<(usize, usize, usize, usize)> {
let mut out = Vec::new();
for (r, row) in t.cells.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
if let Some((rs, cs)) = cell.merged {
out.push((r, c, (rs as usize).max(1), (cs as usize).max(1)));
}
}
}
out
}
fn set_anchors(t: &mut Table, a: &[(usize, usize, usize, usize)]) {
for row in &mut t.cells {
for c in row.iter_mut() {
c.merged = None;
}
}
let (rows, cols) = (t.cells.len(), t.col_widths.len());
for &(r, c, rs, cs) in a {
let (rs, cs) = (rs.min(rows.saturating_sub(r)), cs.min(cols.saturating_sub(c)));
if (rs > 1 || cs > 1)
&& let Some(cell) = t.cells.get_mut(r).and_then(|row| row.get_mut(c))
{
cell.merged = Some((rs as u32, cs as u32));
}
}
}
fn cell_at(p: &Value, t: &Table) -> std::result::Result<(usize, usize), String> {
let r = usize_param(p, "row").ok_or("`row` is required")?;
let c = usize_param(p, "col").ok_or("`col` is required")?;
if r >= t.cells.len() || c >= t.col_widths.len() {
return Err(format!("cell ({r}, {c}) is outside the {}×{} table", t.cells.len(), t.col_widths.len()));
}
Ok((r, c))
}
fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
edit(s, p, "table.set", |t| {
let (r, c) = cell_at(p, t)?;
// Writing into a covered cell writes its merge anchor.
let (r, c) = anchors(t)
.into_iter()
.find(|&(ar, ac, rs, cs)| r >= ar && r < ar + rs && c >= ac && c < ac + cs)
.map(|(ar, ac, ..)| (ar, ac))
.unwrap_or((r, c));
if let Some(cell) = t.cells.get_mut(r).and_then(|row| row.get_mut(c)) {
cell.text = text;
}
Ok(())
})
}
fn run_insert_row(s: &mut Session, p: &Value) -> Result<Value> {
edit(s, p, "table.insertrow", |t| {
if t.cells.len() >= MAX_ROWS {
return Err("too many rows".into());
}
let r = usize_param(p, "row").unwrap_or(t.cells.len()).min(t.cells.len());
let h = p.get("height").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let h = h.or_else(|| t.row_heights.last().copied()).unwrap_or(t.text_height * 5.0 / 3.0);
let a: Vec<_> = anchors(t)
.into_iter()
.map(|(ar, ac, rs, cs)| {
if ar >= r {
(ar + 1, ac, rs, cs)
} else if r < ar + rs {
(ar, ac, rs + 1, cs)
} else {
(ar, ac, rs, cs)
}
})
.collect();
t.row_heights.insert(r, h);
t.cells.insert(r, vec![TableCell { text: String::new(), merged: None }; t.col_widths.len()]);
set_anchors(t, &a);
Ok(())
})
}
fn run_delete_row(s: &mut Session, p: &Value) -> Result<Value> {
edit(s, p, "table.deleterow", |t| {
let r = usize_param(p, "row").ok_or("`row` is required")?;
if r >= t.cells.len() || t.cells.len() <= 1 {
return Err("no such row (a table keeps at least one row)".into());
}
let mut a = Vec::new();
for (ar, ac, rs, cs) in anchors(t) {
if ar == r {
// The anchor row goes: the merge (and its text) moves to the next row.
if rs > 1 {
let text = t.cells.get(ar).and_then(|row| row.get(ac)).map(|c| c.text.clone()).unwrap_or_default();
if let Some(cell) = t.cells.get_mut(ar + 1).and_then(|row| row.get_mut(ac)) {
cell.text = text;
}
a.push((ar, ac, rs - 1, cs));
}
} else if ar > r {
a.push((ar - 1, ac, rs, cs));
} else if r < ar + rs {
a.push((ar, ac, rs - 1, cs));
} else {
a.push((ar, ac, rs, cs));
}
}
t.row_heights.remove(r);
t.cells.remove(r);
if r == 0 {
t.title = false;
}
set_anchors(t, &a);
Ok(())
})
}
fn run_insert_col(s: &mut Session, p: &Value) -> Result<Value> {
edit(s, p, "table.insertcol", |t| {
if t.col_widths.len() >= MAX_COLS {
return Err("too many columns".into());
}
let c = usize_param(p, "col").unwrap_or(t.col_widths.len()).min(t.col_widths.len());
let w = p.get("width").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).or_else(|| t.col_widths.last().copied()).unwrap_or(2.5);
let a: Vec<_> = anchors(t)
.into_iter()
.map(|(ar, ac, rs, cs)| {
if ac >= c {
(ar, ac + 1, rs, cs)
} else if c < ac + cs {
(ar, ac, rs, cs + 1)
} else {
(ar, ac, rs, cs)
}
})
.collect();
t.col_widths.insert(c, w);
for row in &mut t.cells {
row.insert(c.min(row.len()), TableCell { text: String::new(), merged: None });
}
set_anchors(t, &a);
Ok(())
})
}
fn run_delete_col(s: &mut Session, p: &Value) -> Result<Value> {
edit(s, p, "table.deletecol", |t| {
let c = usize_param(p, "col").ok_or("`col` is required")?;
if c >= t.col_widths.len() || t.col_widths.len() <= 1 {
return Err("no such column (a table keeps at least one column)".into());
}
let mut a = Vec::new();
for (ar, ac, rs, cs) in anchors(t) {
if ac == c {
if cs > 1 {
let text = t.cells.get(ar).and_then(|row| row.get(ac)).map(|x| x.text.clone()).unwrap_or_default();
if let Some(cell) = t.cells.get_mut(ar).and_then(|row| row.get_mut(ac + 1)) {
cell.text = text;
}
a.push((ar, ac, rs, cs - 1));
}
} else if ac > c {
a.push((ar, ac - 1, rs, cs));
} else if c < ac + cs {
a.push((ar, ac, rs, cs - 1));
} else {
a.push((ar, ac, rs, cs));
}
}
t.col_widths.remove(c);
for row in &mut t.cells {
if c < row.len() {
row.remove(c);
}
}
set_anchors(t, &a);
Ok(())
})
}
fn run_merge(s: &mut Session, p: &Value) -> Result<Value> {
edit(s, p, "table.merge", |t| {
let (r, c) = cell_at(p, t)?;
let rs = usize_param(p, "rows").unwrap_or(1).max(1).min(t.cells.len() - r);
let cs = usize_param(p, "cols").unwrap_or(1).max(1).min(t.col_widths.len() - c);
if rs == 1 && cs == 1 {
return Err("merge at least two cells (`rows` / `cols`)".into());
}
// Existing merges overlapping the range are absorbed; texts are joined into the anchor.
let overlaps = |&(ar, ac, ars, acs): &(usize, usize, usize, usize)| ar < r + rs && r < ar + ars && ac < c + cs && c < ac + acs;
let mut a: Vec<_> = anchors(t).into_iter().filter(|x| !overlaps(x)).collect();
let mut texts = Vec::new();
for rr in r..r + rs {
for cc in c..c + cs {
if let Some(cell) = t.cells.get_mut(rr).and_then(|row| row.get_mut(cc))
&& !cell.text.is_empty()
{
texts.push(std::mem::take(&mut cell.text));
}
}
}
if let Some(cell) = t.cells.get_mut(r).and_then(|row| row.get_mut(c)) {
cell.text = texts.join(" ");
}
a.push((r, c, rs, cs));
set_anchors(t, &a);
Ok(())
})
}
fn run_unmerge(s: &mut Session, p: &Value) -> Result<Value> {
edit(s, p, "table.unmerge", |t| {
let (r, c) = cell_at(p, t)?;
let a: Vec<_> = anchors(t).into_iter().filter(|&(ar, ac, rs, cs)| !(r >= ar && r < ar + rs && c >= ac && c < ac + cs)).collect();
set_anchors(t, &a);
Ok(())
})
}
/// TABLE at the command line: columns, data rows, insertion point.
struct TableM {
cols: Option<usize>,
rows: Option<usize>,
style: cadcraft_doc::TableStyle,
}
impl TableM {
fn new(s: &Session) -> Self {
TableM { cols: None, rows: None, style: style_of(s) }
}
fn table(&self, at: Vec2) -> Table {
let cols = self.cols.unwrap_or(5);
let rows = self.rows.unwrap_or(1);
let title = self.style.title.then_some("");
let header = self.style.header.then(|| vec![String::new(); cols]);
make_table(&self.style, at, rows, cols, None, None, title, header, &[])
}
}
impl Interactive for TableM {
fn name(&self) -> &'static str {
"TABLE"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.cols, self.rows) {
(None, _) => Prompt::new("Enter number of columns", Accept::NUMBER).default("5"),
(Some(_), None) => Prompt::new("Enter number of data rows", Accept::NUMBER).default("1"),
_ => Prompt::new("Specify insertion point", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let count = |i: &Input, max: usize| -> Option<usize> {
let v = match i {
Input::Text(t) => super::machines::number(t),
_ => None,
}?;
(v.is_finite() && v >= 1.0 && v <= max as f64).then_some(v.round() as usize)
};
match (self.cols, self.rows, &i) {
(_, _, Input::Cancel) => Ok(Step::Cancel),
(None, _, Input::Enter) => {
self.cols = Some(5);
Ok(Step::Continue)
}
(None, _, _) => {
match count(&i, MAX_COLS) {
Some(n) => self.cols = Some(n),
None => s.echo(format!("Requires an integer between 1 and {MAX_COLS}.")),
}
Ok(Step::Continue)
}
(Some(_), None, Input::Enter) => {
self.rows = Some(1);
Ok(Step::Continue)
}
(Some(_), None, _) => {
match count(&i, MAX_ROWS) {
Some(n) => self.rows = Some(n),
None => s.echo(format!("Requires an integer between 1 and {MAX_ROWS}.")),
}
Ok(Step::Continue)
}
(Some(_), Some(_), Input::Point(p)) => {
let t = self.table(*p);
s.add_entity(EntityKind::Table(t))?;
Ok(Step::Done)
}
(Some(_), Some(_), Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.cols.is_some() && self.rows.is_some() { vec![EntityKind::Table(self.table(c))] } else { Vec::new() }
}
}
+5
View File
@@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod assoc;
pub mod cmd;
pub mod grips;
pub mod prompt;
@@ -456,6 +457,8 @@ impl Session {
return Err(EngineError::Internal(id.into(), msg));
}
};
cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok());
assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2));
if spec.undoable
&& let Some((doc, sel, uid)) = before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
@@ -644,6 +647,8 @@ impl Session {
self.pending_window = None;
let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command");
let _ = cancelled;
cmd::constraints::after_command(self, Some(&run.before), true);
assoc::after_command(self, Some(&run.before), None);
if let Ok(st) = self.state_mut()
&& !Arc::ptr_eq(&run.before, &st.doc)
{
+6
View File
@@ -29,6 +29,8 @@ fn dim(kind: DimKind, p13: Vec2, p14: Vec2, defpt: Vec2) -> EntityKind {
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
})
}
@@ -175,6 +177,8 @@ pub fn bracket() -> Drawing {
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
}),
);
add(
@@ -193,6 +197,8 @@ pub fn bracket() -> Drawing {
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
}),
);
+210 -48
View File
@@ -11,9 +11,11 @@ mod mtext;
mod stroke;
pub mod ttf;
use std::sync::Arc;
use cadcraft_geom::{Bounds2, Vec2};
pub use mtext::{MTextLayout, layout_mtext, plain_mtext};
pub use mtext::{MTextColor, MTextLayout, MTextParams, MTextPiece, layout_mtext, layout_mtext_with, plain_mtext};
/// Name of the built-in font.
pub const BUILTIN_FONT: &str = "CADCraft Stroke";
@@ -144,6 +146,116 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 {
(n * height * width_factor - stroke::GAP / stroke::CAP * height * width_factor).max(0.0)
}
/// The font a piece of text is set in: the built-in stroke font or an installed TrueType /
/// OpenType font (outlines).
#[derive(Clone, Debug, Default)]
pub enum TextFont {
#[default]
Stroke,
Outline(Arc<Vec<u8>>),
}
impl TextFont {
/// Resolve a text style font name: an installed TTF/OTF when found, else the stroke font.
pub fn resolve(name: &str) -> TextFont {
ttf::find(name).map(TextFont::Outline).unwrap_or(TextFont::Stroke)
}
pub fn is_outline(&self) -> bool {
matches!(self, TextFont::Outline(_))
}
}
/// Shaped text in local or world coordinates: open strokes (stroke-font glyphs, underlines,
/// fraction bars) and closed glyph outlines grouped per glyph (fill each group even-odd).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shaped {
pub strokes: Vec<Vec<Vec2>>,
pub glyphs: Vec<Vec<Vec<Vec2>>>,
pub width: f64,
}
impl Shaped {
/// Apply a point transform to every vertex.
pub fn map(&mut self, f: impl Fn(Vec2) -> Vec2) {
for s in &mut self.strokes {
for p in s.iter_mut() {
*p = f(*p);
}
}
for g in &mut self.glyphs {
for c in g.iter_mut() {
for p in c.iter_mut() {
*p = f(*p);
}
}
}
}
/// Append another shaped piece (already in the same coordinates).
pub fn extend(&mut self, other: Shaped) {
self.strokes.extend(other.strokes);
self.glyphs.extend(other.glyphs);
}
pub fn is_empty(&self) -> bool {
self.strokes.is_empty() && self.glyphs.is_empty()
}
/// All geometry as polylines (outlines become closed polylines).
pub fn outlines(&self) -> Vec<Vec<Vec2>> {
let mut v = self.strokes.clone();
v.extend(self.glyphs.iter().flatten().cloned());
v
}
}
/// Underline / overline strokes from per-character spans `(x0, x1, underline, overline)`.
pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec<Vec<Vec2>> {
let mut out = Vec::new();
for (which, y) in [(0usize, -h * 0.2), (1usize, h * 1.2)] {
let mut start: Option<f64> = None;
let mut end = 0.0;
for (x0, x1, u, o) in spans {
let on = if which == 0 { *u } else { *o };
match (on, start) {
(true, None) => {
start = Some(*x0);
end = *x1;
}
(true, Some(_)) => end = *x1,
(false, Some(sx)) => {
out.push(vec![Vec2::new(sx, y), Vec2::new(end, y)]);
start = None;
}
(false, None) => {}
}
}
if let Some(sx) = start {
out.push(vec![Vec2::new(sx, y), Vec2::new(end, y)]);
}
}
out
}
/// Shape one line in `font` (baseline at y = 0, x from 0). TrueType fonts that fail to parse
/// fall back to the stroke font.
pub fn shape_line(font: &TextFont, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped {
if let TextFont::Outline(bytes) = font
&& let Some(sh) = ttf::shape(bytes, s, height, width_factor, oblique)
{
return sh;
}
let run = layout_line(s, height, width_factor, oblique);
Shaped { strokes: run.strokes, glyphs: Vec::new(), width: run.width }
}
/// Width of one line in `font`.
pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 {
if let TextFont::Outline(bytes) = font
&& let Some(w) = ttf::width(bytes, s, height, width_factor)
{
return w;
}
line_width(s, height, width_factor)
}
/// Horizontal / vertical alignment for single-line text (matches DXF 72/73 semantics).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Align {
@@ -167,7 +279,100 @@ pub enum VAlign {
Top,
}
/// Place a single-line TEXT in world coordinates. Returns strokes and the text's bounding box.
/// Single-line text placement: insertion, alignment, rotation and the text style's
/// generation flags. Shared by the stroke and TrueType fonts.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextParams {
pub insert: Vec2,
pub align_pt: Option<Vec2>,
pub height: f64,
pub rotation: f64,
pub width_factor: f64,
pub oblique: f64,
pub h: Align,
pub v: VAlign,
/// Mirrored in X (text style "Backwards").
pub backwards: bool,
/// Mirrored in Y (text style "Upside down").
pub upside_down: bool,
}
impl TextParams {
pub fn new(insert: Vec2, height: f64) -> Self {
TextParams {
insert,
align_pt: None,
height,
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
h: Align::Left,
v: VAlign::Baseline,
backwards: false,
upside_down: false,
}
}
/// Centred on `p` both ways (dimension and table text).
pub fn centered(p: Vec2, height: f64, rotation: f64) -> Self {
TextParams { align_pt: Some(p), rotation, h: Align::Middle, v: VAlign::Middle, ..TextParams::new(p, height) }
}
}
/// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its
/// bounding box.
pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) {
let mut height = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 };
let mut wf = p.width_factor;
let mut rot = if p.rotation.is_finite() { p.rotation } else { 0.0 };
let insert = p.insert;
if matches!(p.h, Align::Aligned | Align::Fit)
&& let Some(p2) = p.align_pt
{
let len = insert.dist(p2);
rot = insert.angle_to(p2);
let w = text_width(font, s, height, wf);
if w > 1e-12 && len > 1e-12 {
let k = len / w;
if p.h == Align::Aligned {
height *= k;
} else {
wf *= k;
}
}
}
let mut sh = shape_line(font, s, height, wf, p.oblique);
let width = sh.width;
let origin = match p.h {
Align::Left | Align::Aligned | Align::Fit => insert,
_ => p.align_pt.unwrap_or(insert),
};
let dx = match p.h {
Align::Left | Align::Aligned | Align::Fit => 0.0,
Align::Center | Align::Middle => -width / 2.0,
Align::Right => -width,
};
let dy = match (p.h, p.v) {
(Align::Middle, _) => -height / 2.0,
(_, VAlign::Baseline) => 0.0,
(_, VAlign::Bottom) => height / 3.0,
(_, VAlign::Middle) => -height / 2.0,
(_, VAlign::Top) => -height,
};
let local = Vec2::new(dx, dy);
let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
let xf = |q: Vec2| {
let l = q + local;
origin + Vec2::new(l.x * mx, l.y * my).rotate(rot)
};
sh.map(xf);
let bb = Bounds2::from_points(
[Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf),
);
(sh, bb)
}
/// Place a single-line TEXT in world coordinates with the stroke font. Returns strokes and the
/// text's bounding box.
#[allow(clippy::too_many_arguments)]
pub fn place_text(
s: &str,
@@ -180,52 +385,9 @@ pub fn place_text(
h: Align,
v: VAlign,
) -> (Vec<Vec<Vec2>>, Bounds2) {
let mut height = if height > 0.0 && height.is_finite() { height } else { 1.0 };
let mut wf = width_factor;
let mut rot = rotation;
let mut origin = insert;
if matches!(h, Align::Aligned | Align::Fit)
&& let Some(p2) = align_pt
{
let len = insert.dist(p2);
rot = insert.angle_to(p2);
let w = line_width(s, height, wf);
if w > 1e-12 && len > 1e-12 {
let k = len / w;
if h == Align::Aligned {
height *= k;
} else {
wf *= k;
}
}
}
let run = layout_line(s, height, wf, oblique);
let anchor = match h {
Align::Left | Align::Aligned | Align::Fit => insert,
_ => align_pt.unwrap_or(insert),
};
let dx = match h {
Align::Left | Align::Aligned | Align::Fit => 0.0,
Align::Center | Align::Middle => -run.width / 2.0,
Align::Right => -run.width,
};
let dy = match (h, v) {
(Align::Middle, _) => -height / 2.0,
(_, VAlign::Baseline) => 0.0,
(_, VAlign::Bottom) => height / 3.0,
(_, VAlign::Middle) => -height / 2.0,
(_, VAlign::Top) => -height,
};
if !matches!(h, Align::Left | Align::Aligned | Align::Fit) {
origin = anchor;
}
let local = Vec2::new(dx, dy);
let xf = |p: Vec2| origin + (p + local).rotate(rot);
let strokes: Vec<Vec<Vec2>> = run.strokes.iter().map(|st| st.iter().map(|p| xf(*p)).collect()).collect();
let bb = Bounds2::from_points(
[Vec2::new(0.0, -height / 3.0), Vec2::new(run.width, -height / 3.0), Vec2::new(run.width, height), Vec2::new(0.0, height)].map(xf),
);
(strokes, bb)
let p = TextParams { insert, align_pt, height, rotation, width_factor, oblique, h, v, backwards: false, upside_down: false };
let (sh, bb) = place(&TextFont::Stroke, s, &p);
(sh.strokes, bb)
}
#[cfg(test)]
+565 -40
View File
@@ -2,7 +2,7 @@
use cadcraft_geom::{Bounds2, Vec2};
use crate::{layout_line, line_width};
use crate::{Shaped, TextFont, shape_line, text_width};
/// Strip MTEXT formatting codes to plain text with `\n` paragraph breaks.
/// Stacked fractions `\S1/2;` become `1/2`; `\~` is a space; braces are dropped.
@@ -45,6 +45,10 @@ pub fn plain_mtext(s: &str) -> String {
});
j += 1;
}
// "6\S1/2;" reads as "6 1/2".
if out.chars().last().is_some_and(|c| c.is_ascii_digit()) && stack.starts_with(|c: char| c.is_ascii_digit()) {
out.push(' ');
}
out.push_str(stack.trim_end_matches('/'));
i = j + 1;
}
@@ -73,68 +77,506 @@ pub fn plain_mtext(s: &str) -> String {
out
}
/// A colour set inline with `\C` (ACI) or `\c` (24-bit RGB as 0xRRGGBB).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MTextColor {
Aci(u16),
Rgb(u32),
}
/// MTEXT layout parameters.
#[derive(Clone, Debug)]
pub struct MTextParams {
pub insert: Vec2,
pub height: f64,
/// Column (reference rectangle) width; 0 = no wrapping.
pub width: f64,
/// 1..=9: TL TC TR ML MC MR BL BC BR.
pub attach: u8,
pub rotation: f64,
pub line_spacing: f64,
/// Base font (the text style's), switched inline by `\f` / `\F`.
pub font: TextFont,
pub width_factor: f64,
pub oblique: f64,
}
impl MTextParams {
pub fn new(insert: Vec2, height: f64) -> Self {
MTextParams {
insert,
height,
width: 0.0,
attach: 1,
rotation: 0.0,
line_spacing: 1.0,
font: TextFont::Stroke,
width_factor: 1.0,
oblique: 0.0,
}
}
}
/// Geometry of one colour run (`color` = None: the entity's colour).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MTextPiece {
pub color: Option<MTextColor>,
pub shaped: Shaped,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MTextLayout {
/// Every stroke and glyph outline as polylines, in world coordinates.
pub strokes: Vec<Vec<Vec2>>,
/// Geometry by colour run (strokes plus fillable glyph outlines).
pub pieces: Vec<MTextPiece>,
pub bounds: Bounds2,
/// Plain text of each laid-out line.
pub lines: Vec<String>,
}
/// Lay out MTEXT. `attach` 1..=9 (TL, TC, TR, ML, MC, MR, BL, BC, BR).
pub fn layout_mtext(contents: &str, insert: Vec2, height: f64, width: f64, attach: u8, rotation: f64, line_spacing: f64) -> MTextLayout {
let h = if height > 0.0 && height.is_finite() { height } else { 1.0 };
let text = plain_mtext(contents);
let mut lines: Vec<String> = Vec::new();
for para in text.split('\n') {
if width > 0.0 {
let mut cur = String::new();
for word in para.split(' ') {
let trial = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
if !cur.is_empty() && line_width(&trial, h, 1.0) > width {
lines.push(std::mem::take(&mut cur));
cur = word.to_string();
} else {
cur = trial;
}
}
lines.push(cur);
} else {
lines.push(para.to_string());
/// Inline character format.
#[derive(Clone, Debug)]
struct Fmt {
font: TextFont,
height: f64,
color: Option<MTextColor>,
under: bool,
over: bool,
wf: f64,
oblique: f64,
}
/// One shaped unit of a word.
#[derive(Clone, Debug)]
enum Seg {
Text(String),
/// Stacked text: top, bottom, separator (`/` bar, `#` diagonal, `^` tolerance).
Stack(String, String, char),
}
#[derive(Clone, Debug)]
enum Atom {
Word(Vec<(Seg, usize)>),
Space(usize),
Para(usize),
}
/// Upper bound on parsed atoms (hostile contents).
const MAX_ATOMS: usize = 200_000;
/// Scale of stacked text relative to the surrounding text.
const STACK_SCALE: f64 = 0.7;
fn arg(chars: &[char], from: usize) -> (String, usize) {
let mut j = from;
let mut a = String::new();
while let Some(&ch) = chars.get(j) {
if ch == ';' {
return (a, j + 1);
}
a.push(ch);
j += 1;
}
(a, j)
}
fn num(a: &str) -> Option<f64> {
a.trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
/// `\fArial|b1|i0|c0|p34;` → the font, trying a bold/italic face first.
fn font_switch(a: &str) -> Option<TextFont> {
let mut parts = a.split('|');
let name = parts.next().unwrap_or("").trim();
if name.is_empty() {
return None;
}
let (mut bold, mut italic) = (false, false);
for p in parts {
match p {
"b1" => bold = true,
"i1" => italic = true,
_ => {}
}
}
let pitch = h * 5.0 / 3.0 * if line_spacing > 0.0 && line_spacing.is_finite() { line_spacing } else { 1.0 };
let block_w = if width > 0.0 { width } else { lines.iter().map(|l| line_width(l, h, 1.0)).fold(0.0, f64::max) };
let n = lines.len().max(1) as f64;
let block_h = h + pitch * (n - 1.0);
let col = (attach.clamp(1, 9) - 1) % 3;
let row = (attach.clamp(1, 9) - 1) / 3;
// Top of the first line's cap height relative to the insert point.
let style = match (bold, italic) {
(true, true) => Some("bold italic"),
(true, false) => Some("bold"),
(false, true) => Some("italic"),
_ => None,
};
if let Some(st) = style {
for cand in [format!("{name} {st}"), format!("{name}-{}", st.replace(' ', "")), format!("{name}{}", if bold { "bd" } else { "i" })] {
if let Some(b) = crate::ttf::find(&cand) {
return Some(TextFont::Outline(b));
}
}
}
Some(TextFont::resolve(name))
}
/// Parse MTEXT contents into words, spaces and paragraph breaks with their formats.
fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
let chars: Vec<char> = contents.chars().collect();
let mut fmts = vec![base];
let mut cur = 0usize;
let mut stack: Vec<usize> = Vec::new();
let mut atoms: Vec<Atom> = Vec::new();
let mut word: Vec<(Seg, usize)> = Vec::new();
let push_char = |word: &mut Vec<(Seg, usize)>, c: char, f: usize| {
if let Some((Seg::Text(t), wf)) = word.last_mut()
&& *wf == f
{
t.push(c);
return;
}
word.push((Seg::Text(c.to_string()), f));
};
let end_word = |atoms: &mut Vec<Atom>, word: &mut Vec<(Seg, usize)>| {
if !word.is_empty() {
atoms.push(Atom::Word(std::mem::take(word)));
}
};
// Derive a new format from the current one.
let derive = |fmts: &mut Vec<Fmt>, cur: &mut usize, f: &dyn Fn(&mut Fmt)| {
let mut n = fmts.get(*cur).cloned().unwrap_or(Fmt {
font: TextFont::Stroke,
height: 1.0,
color: None,
under: false,
over: false,
wf: 1.0,
oblique: 0.0,
});
f(&mut n);
if fmts.len() < 100_000 {
fmts.push(n);
*cur = fmts.len() - 1;
}
};
let mut i = 0;
while let Some(&c) = chars.get(i) {
if atoms.len() > MAX_ATOMS {
break;
}
match c {
'\\' => {
let code = chars.get(i + 1).copied().unwrap_or(' ');
i += 2;
match code {
'P' | 'N' => {
end_word(&mut atoms, &mut word);
atoms.push(Atom::Para(cur));
}
'~' => push_char(&mut word, '\u{a0}', cur),
'\\' | '{' | '}' => push_char(&mut word, code, cur),
'L' => derive(&mut fmts, &mut cur, &|f| f.under = true),
'l' => derive(&mut fmts, &mut cur, &|f| f.under = false),
'O' => derive(&mut fmts, &mut cur, &|f| f.over = true),
'o' => derive(&mut fmts, &mut cur, &|f| f.over = false),
'K' | 'k' => {}
'S' => {
let mut j = i;
let (mut top, mut bottom, mut sep) = (String::new(), String::new(), None);
while let Some(&ch) = chars.get(j) {
if ch == ';' {
break;
}
if ch == '\\' {
if let Some(&n) = chars.get(j + 1) {
if sep.is_none() { top.push(n) } else { bottom.push(n) }
}
j += 2;
continue;
}
if sep.is_none() && matches!(ch, '/' | '#' | '^') {
sep = Some(ch);
} else if sep.is_none() {
top.push(ch);
} else {
bottom.push(ch);
}
j += 1;
}
i = j + 1;
word.push((Seg::Stack(top, bottom, sep.unwrap_or('/')), cur));
}
'f' | 'F' => {
let (a, n) = arg(&chars, i);
i = n;
if let Some(font) = font_switch(&a) {
derive(&mut fmts, &mut cur, &|f| f.font = font.clone());
}
}
'H' => {
let (a, n) = arg(&chars, i);
i = n;
let (rel, v) = match a.trim().strip_suffix(['x', 'X']) {
Some(r) => (true, num(r)),
None => (false, num(&a)),
};
if let Some(v) = v.filter(|v| *v > 0.0 && *v < 1e6) {
derive(&mut fmts, &mut cur, &|f| f.height = if rel { f.height * v } else { v });
}
}
'W' => {
let (a, n) = arg(&chars, i);
i = n;
if let Some(v) = num(a.trim_end_matches(['x', 'X'])).filter(|v| *v > 0.01 && *v < 100.0) {
derive(&mut fmts, &mut cur, &|f| f.wf = v);
}
}
'Q' => {
let (a, n) = arg(&chars, i);
i = n;
if let Some(v) = num(&a).filter(|v| v.abs() < 85.0) {
derive(&mut fmts, &mut cur, &|f| f.oblique = v.to_radians());
}
}
'C' => {
let (a, n) = arg(&chars, i);
i = n;
if let Ok(v) = a.trim().parse::<u16>() {
let col = if v == 0 || v >= 256 { None } else { Some(MTextColor::Aci(v)) };
derive(&mut fmts, &mut cur, &|f| f.color = col);
}
}
'c' => {
let (a, n) = arg(&chars, i);
i = n;
if let Ok(v) = a.trim().parse::<u32>() {
derive(&mut fmts, &mut cur, &|f| f.color = Some(MTextColor::Rgb(v & 0xFF_FFFF)));
}
}
_ => {
// \A, \T, \p and unknown codes: argument up to ';'.
let (_, n) = arg(&chars, i);
i = n;
}
}
}
'{' => {
if stack.len() < 256 {
stack.push(cur);
}
i += 1;
}
'}' => {
if let Some(f) = stack.pop() {
cur = f;
}
i += 1;
}
'\r' => i += 1,
'\n' => {
end_word(&mut atoms, &mut word);
atoms.push(Atom::Para(cur));
i += 1;
}
' ' => {
end_word(&mut atoms, &mut word);
atoms.push(Atom::Space(cur));
i += 1;
}
_ => {
push_char(&mut word, c, cur);
i += 1;
}
}
}
end_word(&mut atoms, &mut word);
(atoms, fmts)
}
fn seg_width(seg: &Seg, f: &Fmt) -> f64 {
match seg {
Seg::Text(t) => text_width(&f.font, t, f.height, f.wf),
Seg::Stack(top, bottom, sep) => {
let hs = f.height * STACK_SCALE;
let wt = text_width(&f.font, top, hs, f.wf);
let wb = text_width(&f.font, bottom, hs, f.wf);
match sep {
'#' => wt + wb + f.height * 0.5,
'^' => wt.max(wb),
_ => wt.max(wb) + f.height * 0.2,
}
}
}
}
/// Shape a segment with its left end at `x` on the baseline.
fn seg_shape(seg: &Seg, f: &Fmt, x: f64) -> Shaped {
let h = f.height;
let mut out = Shaped::default();
let put = |s: &str, size: f64, at: Vec2| {
if s.is_empty() {
return Shaped::default();
}
let mut sh = shape_line(&f.font, s, size, f.wf, f.oblique);
sh.map(|p| p + at);
sh
};
let w = seg_width(seg, f);
match seg {
Seg::Text(t) => out.extend(put(t, h, Vec2::new(x, 0.0))),
Seg::Stack(top, bottom, sep) => {
let hs = h * STACK_SCALE;
let wt = text_width(&f.font, top, hs, f.wf);
let wb = text_width(&f.font, bottom, hs, f.wf);
match sep {
'#' => {
out.extend(put(top, hs, Vec2::new(x, h * 0.4)));
let sx = x + wt + h * 0.1;
out.extend(put(bottom, hs, Vec2::new(sx + h * 0.4, -h * 0.05)));
out.strokes.push(vec![Vec2::new(sx, -h * 0.1), Vec2::new(sx + h * 0.3, h * 1.1)]);
}
'^' => {
out.extend(put(top, hs, Vec2::new(x, h * 0.55)));
out.extend(put(bottom, hs, Vec2::new(x, h * 0.45 - hs)));
}
_ => {
let bar = h * 0.45;
out.extend(put(top, hs, Vec2::new(x + (w - wt) / 2.0, bar + h * 0.12)));
out.extend(put(bottom, hs, Vec2::new(x + (w - wb) / 2.0, bar - h * 0.12 - hs)));
out.strokes.push(vec![Vec2::new(x, bar), Vec2::new(x + w, bar)]);
}
}
}
}
if f.under {
out.strokes.push(vec![Vec2::new(x, -h * 0.2), Vec2::new(x + w, -h * 0.2)]);
}
if f.over {
out.strokes.push(vec![Vec2::new(x, h * 1.2), Vec2::new(x + w, h * 1.2)]);
}
out
}
fn seg_plain(seg: &Seg) -> String {
match seg {
Seg::Text(t) => t.replace('\u{a0}', " "),
Seg::Stack(a, b, _) => format!("{a}/{b}"),
}
}
#[derive(Default)]
struct Line {
items: Vec<(Seg, usize, f64)>,
width: f64,
max_h: f64,
text: String,
}
/// Lay out MTEXT with the stroke font. `attach` 1..=9 (TL, TC, TR, ML, MC, MR, BL, BC, BR).
pub fn layout_mtext(contents: &str, insert: Vec2, height: f64, width: f64, attach: u8, rotation: f64, line_spacing: f64) -> MTextLayout {
layout_mtext_with(contents, &MTextParams { insert, height, width, attach, rotation, line_spacing, ..MTextParams::new(insert, height) })
}
/// Lay out MTEXT: inline formatting runs, stacked fractions, paragraphs, word wrap to the
/// column width and the attachment point.
pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
let h = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 };
let wf = if p.width_factor.is_finite() && p.width_factor > 0.0 { p.width_factor } else { 1.0 };
let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 };
let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 };
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique };
let (atoms, fmts) = parse(contents, base.clone());
let fmt = |i: usize| fmts.get(i).unwrap_or(&base);
let mut lines: Vec<Line> = Vec::new();
let mut line = Line::default();
let mut pending_space: Option<usize> = None;
let mut last_fmt = 0usize;
let finish = |lines: &mut Vec<Line>, line: &mut Line, f: &Fmt| {
if line.items.is_empty() {
line.max_h = f.height;
}
lines.push(std::mem::take(line));
};
for a in &atoms {
match a {
Atom::Space(f) => {
if !line.items.is_empty() || pending_space.is_some() {
pending_space = Some(*f);
}
last_fmt = *f;
}
Atom::Para(f) => {
pending_space = None;
finish(&mut lines, &mut line, fmt(*f));
last_fmt = *f;
}
Atom::Word(segs) => {
let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum();
let sw = pending_space.map(|f| text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf)).unwrap_or(0.0);
if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > width + 1e-9 {
finish(&mut lines, &mut line, fmt(last_fmt));
} else if !line.items.is_empty() || pending_space.is_some() {
line.width += sw;
if pending_space.is_some() {
line.text.push(' ');
}
}
pending_space = None;
for (sg, f) in segs {
let fw = seg_width(sg, fmt(*f));
line.max_h = line.max_h.max(fmt(*f).height);
line.text.push_str(&seg_plain(sg));
line.items.push((sg.clone(), *f, line.width));
line.width += fw;
last_fmt = *f;
}
}
}
}
finish(&mut lines, &mut line, fmt(last_fmt));
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
// Baselines below the top of the first line.
let mut baselines = Vec::with_capacity(lines.len());
let mut y = 0.0;
for (i, l) in lines.iter().enumerate() {
let lh = if l.max_h > 0.0 { l.max_h } else { h };
y -= if i == 0 { lh } else { lh * 5.0 / 3.0 * ls };
baselines.push(y);
}
let block_h = -y;
let block_w = if width > 0.0 { width } else { lines.iter().map(|l| l.width).fold(0.0, f64::max) };
let col = (p.attach.clamp(1, 9) - 1) % 3;
let row = (p.attach.clamp(1, 9) - 1) / 3;
let top = match row {
0 => 0.0,
1 => block_h / 2.0,
_ => block_h,
};
let mut out = MTextLayout { lines: lines.clone(), ..Default::default() };
for (i, line) in lines.iter().enumerate() {
let run = layout_line(line, h, 1.0, 0.0);
let x = match col {
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
for (l, base_y) in lines.iter().zip(baselines) {
let x0 = match col {
0 => 0.0,
1 => -run.width / 2.0,
_ => -run.width,
1 => -l.width / 2.0,
_ => -l.width,
};
let base_y = top - h - pitch * i as f64;
for st in run.strokes {
out.strokes.push(st.into_iter().map(|p| insert + (p + Vec2::new(x, base_y)).rotate(rotation)).collect());
for (sg, f, x) in &l.items {
let fm = fmt(*f);
let mut sh = seg_shape(sg, fm, *x);
let off = Vec2::new(x0, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
Some(pc) if pc.color == fm.color => pc.shaped.extend(sh),
_ => out.pieces.push(MTextPiece { color: fm.color, shaped: sh }),
}
}
}
for pc in &out.pieces {
out.strokes.extend(pc.shaped.outlines());
}
let x0 = match col {
0 => 0.0,
1 => -block_w / 2.0,
_ => -block_w,
};
out.bounds = Bounds2::from_points(
[Vec2::new(x0, top), Vec2::new(x0 + block_w, top), Vec2::new(x0 + block_w, top - block_h), Vec2::new(x0, top - block_h)]
.map(|p| insert + p.rotate(rotation)),
[Vec2::new(x0, top), Vec2::new(x0 + block_w, top), Vec2::new(x0 + block_w, top - block_h), Vec2::new(x0, top - block_h)].map(xf),
);
out
}
@@ -146,7 +588,7 @@ mod tests {
#[test]
fn strips_formatting() {
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
assert_eq!(plain_mtext("1\\S1/2;\""), "11/2\"");
assert_eq!(plain_mtext("1\\S1/2;\""), "1 1/2\"");
assert_eq!(plain_mtext("a\\~b\\\\c"), "a b\\c");
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
@@ -166,4 +608,87 @@ mod tests {
let c = l.bounds.center();
assert!((c.x - 5.0).abs() < 1e-9 && (c.y - 5.0).abs() < 1e-9);
}
fn lay(s: &str, width: f64) -> MTextLayout {
layout_mtext(s, Vec2::ZERO, 1.0, width, 1, 0.0, 1.0)
}
#[test]
fn paragraphs_make_lines() {
let l = lay("one\\Ptwo\\P\\Pfour", 0.0);
assert_eq!(l.lines, vec!["one", "two", "", "four"]);
// Four lines: cap height plus three pitches of 5/3.
assert!((l.bounds.height() - (1.0 + 3.0 * 5.0 / 3.0)).abs() < 1e-9);
}
#[test]
fn absolute_and_relative_height() {
let small = lay("AB", 0.0).bounds.width();
let abs = lay("\\H2;AB", 0.0);
assert!((abs.bounds.width() - 2.0 * small).abs() < 1e-6);
assert!((abs.bounds.height() - 2.0).abs() < 1e-9);
let rel = lay("{\\H3x;AB}AB", 0.0);
assert!((rel.bounds.width() - 4.0 * small).abs() < 1e-6, "{}", rel.bounds.width());
assert!((rel.bounds.height() - 3.0).abs() < 1e-9);
}
#[test]
fn colour_runs() {
let l = lay("A{\\C1;B}C", 0.0);
let cols: Vec<Option<MTextColor>> = l.pieces.iter().map(|p| p.color).collect();
assert_eq!(cols, vec![None, Some(MTextColor::Aci(1)), None]);
let l = lay("\\c16711680;R", 0.0);
assert_eq!(l.pieces.first().map(|p| p.color), Some(Some(MTextColor::Rgb(0xFF0000))));
}
#[test]
fn underline_and_overline() {
let plain = lay("AB", 0.0).strokes.len();
let u = lay("\\LAB\\l", 0.0);
assert_eq!(u.strokes.len(), plain + 1);
// Top-left attachment: the first baseline is one text height below the insert.
assert!(u.strokes.iter().any(|s| s.len() == 2 && s.iter().all(|p| (p.y + 1.2).abs() < 1e-9)));
let o = lay("\\OAB\\o", 0.0);
assert!(o.strokes.iter().any(|s| s.len() == 2 && s.iter().all(|p| (p.y - 0.2).abs() < 1e-9)));
}
#[test]
fn stacked_fraction_is_small_over_large() {
let l = lay("\\S1/2;", 0.0);
assert_eq!(l.lines, vec!["1/2"]);
// A horizontal bar plus numerator above it and denominator below.
let bar = l.strokes.iter().find(|s| s.len() == 2 && (s[0].y - s[1].y).abs() < 1e-12 && (s[0].y + 0.55).abs() < 1e-9);
assert!(bar.is_some());
let ys: Vec<f64> = l.strokes.iter().flatten().map(|p| p.y).collect();
let max = ys.iter().cloned().fold(f64::MIN, f64::max);
let min = ys.iter().cloned().fold(f64::MAX, f64::min);
assert!(max > 0.0 && min < -1.0, "{min}..{max}");
// Narrower than the two digits side by side at full size.
assert!(l.bounds.width() < crate::line_width("12", 1.0, 1.0));
}
#[test]
fn wrap_respects_runs_and_width() {
let l = lay("alpha {\\H2x;beta} gamma delta", 6.0);
assert!(l.lines.len() >= 2);
let w: f64 = crate::line_width("alpha", 1.0, 1.0);
assert!(w < 6.0);
assert_eq!(l.lines.first().map(String::as_str), Some("alpha"));
}
#[test]
fn font_switch_falls_back_to_stroke() {
let l = lay("{\\fNoSuchFontAnywhere|b1;AB}", 0.0);
assert!(!l.strokes.is_empty());
assert!(l.pieces.iter().all(|p| p.shaped.glyphs.is_empty()));
}
#[test]
fn hostile_contents_do_not_panic() {
for s in ["\\", "\\S", "\\S/;", "{{{{", "}}}}", "\\H;", "\\H-1;x", "\\Hx;", "\\C999;a", "\\f;", "\\W0;a", "\\Q90;a", "\\P\\P\\P"] {
let _ = lay(s, 1.0);
}
let long = "word ".repeat(5000);
assert!(lay(&long, 10.0).lines.len() > 100);
}
}
+110 -38
View File
@@ -9,7 +9,7 @@ use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{FontRef, MetadataProvider};
use crate::Run;
use crate::{Run, Shaped};
/// Font bytes by lower-case family/file stem.
type Db = HashMap<String, Arc<Vec<u8>>>;
@@ -116,23 +116,38 @@ pub fn find(name: &str) -> Option<Arc<Vec<u8>>> {
None
}
/// A glyph outline in font units (at 1000 units per em), flattened, plus its advance.
struct GlyphOutline {
contours: Vec<Vec<Vec2>>,
advance: f64,
}
/// Cached glyph outlines keyed by (font bytes address, length, char). Fonts live for the whole
/// process in the font table, so the address identifies them.
type GlyphCache = HashMap<(usize, usize, char), Arc<GlyphOutline>>;
fn glyph_cache() -> &'static Mutex<GlyphCache> {
static C: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
const MAX_CACHED_GLYPHS: usize = 40_000;
const UPEM: f32 = 1000.0;
struct Pen {
contours: Vec<Vec<Vec2>>,
cur: Vec<Vec2>,
scale: f64,
dx: f64,
wf: f64,
shear: f64,
}
impl Pen {
fn pt(&self, x: f32, y: f32) -> Vec2 {
let y = f64::from(y) * self.scale;
Vec2::new(self.dx + f64::from(x) * self.scale * self.wf + y * self.shear, y)
fn pt(x: f32, y: f32) -> Vec2 {
Vec2::new(f64::from(x), f64::from(y))
}
fn flush(&mut self) {
if self.cur.len() > 1 {
if let Some(f) = self.cur.first().copied() {
if self.cur.len() > 2 {
if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied())
&& f != l
{
self.cur.push(f);
}
self.contours.push(std::mem::take(&mut self.cur));
@@ -145,17 +160,15 @@ impl Pen {
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.flush();
let p = self.pt(x, y);
self.cur.push(p);
self.cur.push(Self::pt(x, y));
}
fn line_to(&mut self, x: f32, y: f32) {
let p = self.pt(x, y);
self.cur.push(p);
self.cur.push(Self::pt(x, y));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
let a = self.cur.last().copied().unwrap_or_default();
let c = self.pt(cx0, cy0);
let b = self.pt(x, y);
let c = Self::pt(cx0, cy0);
let b = Self::pt(x, y);
for i in 1..=6 {
let t = f64::from(i) / 6.0;
let u = 1.0 - t;
@@ -164,9 +177,9 @@ impl OutlinePen for Pen {
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
let a = self.cur.last().copied().unwrap_or_default();
let c0 = self.pt(cx0, cy0);
let c1 = self.pt(cx1, cy1);
let b = self.pt(x, y);
let c0 = Self::pt(cx0, cy0);
let c1 = Self::pt(cx1, cy1);
let b = Self::pt(x, y);
for i in 1..=8 {
let t = f64::from(i) / 8.0;
let u = 1.0 - t;
@@ -178,25 +191,84 @@ impl OutlinePen for Pen {
}
}
/// Cap height of a font in font units at 1000 units per em.
fn cap_height(f: &FontRef) -> f64 {
let metrics = f.metrics(Size::new(UPEM), LocationRef::default());
f64::from(metrics.cap_height.filter(|c| *c > 0.0).unwrap_or(metrics.ascent * 0.72).max(1.0))
}
fn glyph(font: &[u8], f: &FontRef, c: char) -> Arc<GlyphOutline> {
let key = (font.as_ptr() as usize, font.len(), c);
if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
return g.clone();
}
let gid = f.charmap().map(c).unwrap_or_default();
let mut pen = Pen { contours: Vec::new(), cur: Vec::new() };
if let Some(g) = f.outline_glyphs().get(gid) {
let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen);
pen.flush();
}
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
let g = Arc::new(GlyphOutline { contours: pen.contours, advance });
let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner);
if cache.len() > MAX_CACHED_GLYPHS {
cache.clear();
}
cache.insert(key, g.clone());
g
}
/// Shape one line with a TrueType font: closed glyph contours grouped per glyph (baseline at
/// y = 0, x from 0) plus `%%u`/`%%o` decorations as strokes. Text height = cap height.
pub fn shape(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option<Shaped> {
let f = FontRef::new(font).ok()?;
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let scale = h / cap_height(&f);
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
let shear = oblique.tan().clamp(-10.0, 10.0);
let mut out = Shaped::default();
let mut spans = Vec::new();
let mut x = 0.0;
for (c, under, over) in crate::decode_controls(s) {
let g = glyph(font, &f, c);
if !c.is_whitespace() {
let contours: Vec<Vec<Vec2>> = g
.contours
.iter()
.map(|ct| {
ct.iter()
.map(|p| {
let y = p.y * scale;
Vec2::new(x + p.x * scale * wf + y * shear, y)
})
.collect()
})
.collect();
if !contours.is_empty() {
out.glyphs.push(contours);
}
}
let adv = g.advance * scale * wf;
spans.push((x, x + adv, under, over));
x += adv;
}
out.strokes.extend(crate::decorations(&spans, h));
out.width = x;
Some(out)
}
/// Width of one line set in a TrueType font.
pub fn width(font: &[u8], s: &str, height: f64, width_factor: f64) -> Option<f64> {
let f = FontRef::new(font).ok()?;
let scale = height / cap_height(&f);
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
Some(crate::decode_controls(s).iter().map(|(c, _, _)| glyph(font, &f, *c).advance * scale * wf).sum())
}
/// Lay out one line with a TrueType font: closed glyph contours (baseline at y = 0).
pub fn layout_line(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option<Run> {
let f = FontRef::new(font).ok()?;
let upem = 1000.0f32;
let metrics = f.metrics(Size::new(upem), LocationRef::default());
let cap = metrics.cap_height.filter(|c| *c > 0.0).unwrap_or(metrics.ascent * 0.72).max(1.0);
let scale = height / f64::from(cap);
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
let gm = f.glyph_metrics(Size::new(upem), LocationRef::default());
let charmap = f.charmap();
let outlines = f.outline_glyphs();
let mut pen = Pen { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, wf, shear: oblique.tan().clamp(-10.0, 10.0) };
for (c, _, _) in crate::decode_controls(s) {
let gid = charmap.map(c).unwrap_or_default();
if let Some(g) = outlines.get(gid) {
let _ = g.draw(DrawSettings::unhinted(Size::new(upem), LocationRef::default()), &mut pen);
pen.flush();
}
pen.dx += f64::from(gm.advance_width(gid).unwrap_or(upem * 0.5)) * scale * wf;
}
Some(Run { strokes: pen.contours, width: pen.dx })
let sh = shape(font, s, height, width_factor, oblique)?;
let mut strokes: Vec<Vec<Vec2>> = sh.glyphs.into_iter().flatten().collect();
strokes.extend(sh.strokes);
Some(Run { strokes, width: sh.width })
}
+2
View File
@@ -262,6 +262,8 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
text_rotation: t.fd(53, 0.0).to_radians(),
user_text_pos: t.i(70).unwrap_or(0) & 128 != 0,
block: t.s(2).filter(|s| !s.is_empty()),
overrides: Default::default(),
assoc: Vec::new(),
})
}
"LEADER" => EntityKind::Leader(Leader {
+2
View File
@@ -90,6 +90,8 @@ fn sample() -> Drawing {
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
}),
)
.unwrap();
+3
View File
@@ -14,3 +14,6 @@ cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
cadcraft-fonts = { workspace = true }
tiny-skia = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+823 -107
View File
File diff suppressed because it is too large. Load diff
+224 -101
View File
@@ -14,12 +14,13 @@ mod hatch;
mod linetype;
pub mod paper;
pub mod raster;
pub mod units;
use cadcraft_color::{Color, Rgb};
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Lineweight, Prim, Space};
use cadcraft_geom::{Bounds2, Mat3, Polyline, Vec2};
pub use dim::dimension_geometry;
pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, dimension_geometry, dimension_geometry_with, format_linear_value};
pub use fill::triangulate_evenodd;
pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
@@ -142,6 +143,26 @@ impl Builder<'_> {
}
self.list.prims.push(DPrim { handle: ctx.top, color, lw: 0.0, kind: Kind::Tris, start, len: tris.len() as u32 });
}
/// Shaped text: strokes as polylines; TrueType glyphs filled (TEXTFILL) or outlined.
fn shaped(&mut self, ctx: &Ctx, color: Rgb, lw: f32, sh: &cadcraft_fonts::Shaped) {
for s in &sh.strokes {
self.polyline(ctx, color, lw, s);
}
if sh.glyphs.is_empty() {
return;
}
let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0;
for g in &sh.glyphs {
if fill {
let tris = fill::triangulate_evenodd(g);
self.tris(ctx, color, &tris);
} else {
for c in g {
self.polyline(ctx, color, lw, c);
}
}
}
}
fn point(&mut self, ctx: &Ctx, color: Rgb, p: Vec2) {
let q = ctx.xf.apply(p);
self.list.bounds.add(q);
@@ -366,39 +387,18 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
if !b.opts.text {
return;
}
let (h, v) = align(t.halign, t.valign);
let oblique = t.oblique;
let (strokes, _) =
cadcraft_fonts::place_text(&t.value, t.insert.xy(), t.align_pt.map(|p| p.xy()), t.height, t.rotation, t.width_factor, oblique, h, v);
for s in strokes {
b.polyline(ctx, rgb, lw, &s);
}
let (sh, _) = place_text_entity(ctx.d, t, &t.value);
b.shaped(ctx, rgb, lw, &sh);
}
EntityKind::AttDef(a) => {
let (h, v) = align(a.text.halign, a.text.valign);
let (strokes, _) = cadcraft_fonts::place_text(
&a.tag,
a.text.insert.xy(),
a.text.align_pt.map(|p| p.xy()),
a.text.height,
a.text.rotation,
a.text.width_factor,
a.text.oblique,
h,
v,
);
for s in strokes {
b.polyline(ctx, rgb, lw, &s);
}
let (sh, _) = place_text_entity(ctx.d, &a.text, &a.tag);
b.shaped(ctx, rgb, lw, &sh);
}
EntityKind::MText(t) => {
if !b.opts.text {
return;
}
let l = cadcraft_fonts::layout_mtext(&t.contents, t.insert.xy(), t.height, t.width, t.attach, t.rotation, t.line_spacing);
for s in l.strokes {
b.polyline(ctx, rgb, lw, &s);
}
mtext(b, ctx, t, rgb, lw);
}
EntityKind::Insert(ins) => insert(b, ctx, e, ins, rgb),
EntityKind::Dimension(dm) => {
@@ -410,19 +410,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
entity(b, &sub, be);
}
} else {
let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default();
let g = dim::dimension_geometry(dm, &style, ctx.d.header.f64("DIMSCALE", 1.0));
for l in &g.lines {
b.polyline(ctx, rgb, lw, l);
}
for t in &g.fills {
b.tris(ctx, rgb, t);
}
if b.opts.text {
for s in &g.text {
b.polyline(ctx, rgb, lw, s);
}
}
dimension(b, ctx, e, dm, lw);
}
}
EntityKind::Hatch(h) => {
@@ -494,54 +482,21 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
b.polyline(ctx, rgb, lw, &[o, o + u + v]);
b.polyline(ctx, rgb, lw, &[o + u, o + v]);
}
EntityKind::Table(t) => {
let o = t.insert.xy();
let w: f64 = t.col_widths.iter().sum();
let mut y = 0.0;
b.polyline(ctx, rgb, lw, &[o, o + Vec2::new(w, 0.0)]);
for (r, rh) in t.row_heights.iter().enumerate() {
let mut x = 0.0;
for (c, cw) in t.col_widths.iter().enumerate() {
if let Some(cell) = t.cells.get(r).and_then(|row| row.get(c))
&& !cell.text.is_empty()
&& b.opts.text
{
let center = o + Vec2::new(x + cw / 2.0, -(y + rh / 2.0));
let (strokes, _) = cadcraft_fonts::place_text(
&cell.text,
center,
Some(center),
t.text_height,
0.0,
1.0,
0.0,
cadcraft_fonts::Align::Middle,
cadcraft_fonts::VAlign::Middle,
);
for s in strokes {
b.polyline(ctx, rgb, lw, &s);
}
}
x += cw;
}
y += rh;
b.polyline(ctx, rgb, lw, &[o + Vec2::new(0.0, -y), o + Vec2::new(w, -y)]);
}
let mut x = 0.0;
b.polyline(ctx, rgb, lw, &[o, o + Vec2::new(0.0, -y)]);
for cw in &t.col_widths {
x += cw;
b.polyline(ctx, rgb, lw, &[o + Vec2::new(x, 0.0), o + Vec2::new(x, -y)]);
}
}
EntityKind::Table(t) => table(b, ctx, t, rgb, lw),
EntityKind::Leader(l) => {
let pts: Vec<Vec2> = l.vertices.iter().map(|v| v.xy()).collect();
stroke(b, &pts);
if l.arrow
&& let (Some(a), Some(n)) = (pts.first(), pts.get(1))
{
let size = ctx.d.dim_style(&l.style).map(|s| s.arrow_size).unwrap_or(0.18) * ctx.d.header.f64("DIMSCALE", 1.0);
b.tris(ctx, rgb, &dim::arrow(*a, (*a - *n).normalized(), size));
let st = ctx.d.dim_style(&l.style);
let size = st.map(|s| s.arrow_size).unwrap_or(0.18) * ctx.d.header.f64("DIMSCALE", 1.0);
let kind = dim::Arrowhead::parse(st.map(|s| s.arrow_block.as_str()).unwrap_or(""));
let g = dim::arrowhead(kind, *a, (*a - *n).normalized(), size);
for l in &g.lines {
b.polyline(ctx, rgb, lw, l);
}
b.tris(ctx, rgb, &g.tris);
}
}
EntityKind::MLeader(m) => {
@@ -556,10 +511,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
let land = m.landing.xy();
let dir = if t.insert.x >= land.x { 1.0 } else { -1.0 };
b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]);
let l = cadcraft_fonts::layout_mtext(&t.contents, t.insert.xy(), t.height, t.width, t.attach, t.rotation, t.line_spacing);
for s in l.strokes {
b.polyline(ctx, rgb, lw, &s);
}
mtext(b, ctx, t, rgb, lw);
}
}
EntityKind::Point(p) => b.point(ctx, rgb, p.p.xy()),
@@ -594,6 +546,190 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
}
}
/// The text style of an entity (Standard defaults when missing).
fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle {
d.text_style(name).cloned().unwrap_or_default()
}
/// Shape a TEXT / ATTRIB / ATTDEF with its style's font and generation flags.
pub fn place_text_entity(d: &Drawing, t: &cadcraft_doc::Text, value: &str) -> (cadcraft_fonts::Shaped, Bounds2) {
let ts = text_style(d, &t.style);
let font = cadcraft_fonts::TextFont::resolve(&ts.font);
let (h, v) = align(t.halign, t.valign);
let p = cadcraft_fonts::TextParams {
insert: t.insert.xy(),
align_pt: t.align_pt.map(|p| p.xy()),
height: t.height,
rotation: t.rotation,
width_factor: t.width_factor,
oblique: t.oblique,
h,
v,
backwards: ts.backwards,
upside_down: ts.upside_down,
};
cadcraft_fonts::place(&font, value, &p)
}
/// Lay out an MTEXT with its style's font.
pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fonts::MTextLayout {
let ts = text_style(d, &t.style);
let params = cadcraft_fonts::MTextParams {
insert: t.insert.xy(),
height: t.height,
width: t.width,
attach: t.attach,
rotation: t.rotation,
line_spacing: t.line_spacing,
font: cadcraft_fonts::TextFont::resolve(&ts.font),
width_factor: ts.width_factor,
oblique: ts.oblique,
};
cadcraft_fonts::layout_mtext_with(&t.contents, &params)
}
fn mtext_color(c: Option<cadcraft_fonts::MTextColor>, rgb: Rgb) -> Rgb {
match c {
Some(cadcraft_fonts::MTextColor::Aci(i)) => {
u8::try_from(i).ok().filter(|i| *i > 0).map(|i| Color::Index(i).resolve(Color::Index(7), Color::Index(7))).unwrap_or(rgb)
}
Some(cadcraft_fonts::MTextColor::Rgb(v)) => Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8),
None => rgb,
}
}
fn mtext(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::MText, rgb: Rgb, lw: f32) {
let l = layout_mtext_entity(ctx.d, t);
for p in &l.pieces {
b.shaped(ctx, mtext_color(p.color, rgb), lw, &p.shaped);
}
}
/// The font settings of a dimension style's text style.
pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText {
let ts = text_style(d, &st.text_style);
DimText { font: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique }
}
/// Geometry of a dimension in a drawing: its style, overrides and text font.
pub fn dimension_in(d: &Drawing, dm: &cadcraft_doc::Dimension) -> DimGeometry {
let style = d.dim_style(&dm.style).cloned().unwrap_or_default();
let st = style.with_overrides(&dm.overrides);
dim::dimension_geometry_with(dm, &style, d.header.f64("DIMSCALE", 1.0), &dim_text(d, &st))
}
fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimension, lw: f32) {
let d = ctx.d;
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
let g = dimension_in(d, dm);
// DIMCLRD / DIMCLRE / DIMCLRT: ByBlock = the dimension's own colour.
let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
let layer_color = d.layer(layer_name).map(|l| l.color).unwrap_or(Color::Index(7));
let own = match e.common.color {
Color::ByLayer => layer_color,
Color::ByBlock => ctx.block_color,
c => c,
};
let dim_rgb = style.dim_line_color.resolve(layer_color, own);
let ext_rgb = style.ext_line_color.resolve(layer_color, own);
let txt_rgb = style.text_color.resolve(layer_color, own);
for (l, role) in g.lines.iter().zip(g.line_roles.iter().chain(std::iter::repeat(&LineRole::Dim))) {
b.polyline(ctx, if *role == LineRole::Ext { ext_rgb } else { dim_rgb }, lw, l);
}
for t in &g.fills {
b.tris(ctx, dim_rgb, t);
}
if b.opts.text {
let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0 };
b.shaped(ctx, txt_rgb, lw, &sh);
}
}
/// Covered cells of a table (inside another cell's merge, not its anchor).
pub fn table_covered(t: &cadcraft_doc::Table) -> Vec<Vec<bool>> {
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
let mut cov = vec![vec![false; cols]; rows];
for (r, row) in t.cells.iter().enumerate().take(rows) {
for (c, cell) in row.iter().enumerate().take(cols) {
if let Some((rs, cs)) = cell.merged {
for rr in r..(r + rs.max(1) as usize).min(rows) {
for cc in c..(c + cs.max(1) as usize).min(cols) {
if (rr, cc) != (r, c)
&& let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc))
{
*x = true;
}
}
}
}
}
}
cov
}
fn table(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::Table, rgb: Rgb, lw: f32) {
let o = t.insert.xy();
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
let xs: Vec<f64> = std::iter::once(0.0)
.chain(t.col_widths.iter().take(cols).scan(0.0, |a, w| {
*a += w;
Some(*a)
}))
.collect();
let ys: Vec<f64> = std::iter::once(0.0)
.chain(t.row_heights.iter().take(rows).scan(0.0, |a, h| {
*a += h;
Some(*a)
}))
.collect();
let cov = table_covered(t);
let style = ctx.d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&t.style)).cloned().unwrap_or_default();
let ts = text_style(ctx.d, "Standard");
let font = cadcraft_fonts::TextFont::resolve(&ts.font);
let x_at = |c: usize| xs.get(c).copied().unwrap_or(0.0);
let y_at = |r: usize| ys.get(r).copied().unwrap_or(0.0);
for r in 0..rows {
for c in 0..cols {
if cov.get(r).and_then(|v| v.get(c)).copied().unwrap_or(false) {
continue;
}
let cell = t.cells.get(r).and_then(|row| row.get(c));
let (rs, cs) = cell.and_then(|x| x.merged).map(|(a, b)| (a.max(1) as usize, b.max(1) as usize)).unwrap_or((1, 1));
let (r2, c2) = ((r + rs).min(rows), (c + cs).min(cols));
let (x0, x1, y0, y1) = (x_at(c), x_at(c2), y_at(r), y_at(r2));
let p = |x: f64, y: f64| o + Vec2::new(x, -y);
// Each cell draws its bottom and right edges; the outer top/left come from row 0 / col 0.
b.polyline(ctx, rgb, lw, &[p(x0, y1), p(x1, y1), p(x1, y0)]);
if r == 0 {
b.polyline(ctx, rgb, lw, &[p(x0, y0), p(x1, y0)]);
}
if c == 0 {
b.polyline(ctx, rgb, lw, &[p(x0, y0), p(x0, y1)]);
}
let Some(cell) = cell.filter(|x| !x.text.is_empty() && b.opts.text) else { continue };
let title = t.title && r == 0;
let header = t.header && r == usize::from(t.title);
let h = if title { t.text_height * 1.4 } else { t.text_height };
// Titles and headers are centred; data sits middle-left inside the cell margin.
let params = if title || header {
cadcraft_fonts::TextParams::centered(p((x0 + x1) / 2.0, (y0 + y1) / 2.0), h, 0.0)
} else {
let at = p(x0 + style.margin.max(0.0), (y0 + y1) / 2.0);
cadcraft_fonts::TextParams {
align_pt: Some(at),
h: cadcraft_fonts::Align::Left,
v: cadcraft_fonts::VAlign::Middle,
..cadcraft_fonts::TextParams::new(at, h)
}
};
let (sh, _) = cadcraft_fonts::place(&font, &cell.text, &params);
b.shaped(ctx, rgb, lw, &sh);
}
}
}
fn align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) {
use cadcraft_fonts::{Align as A, VAlign as V};
let ha = match h {
@@ -670,21 +806,8 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
if a.invisible {
continue;
}
let (h, v) = align(a.text.halign, a.text.valign);
let (strokes, _) = cadcraft_fonts::place_text(
&a.text.value,
a.text.insert.xy(),
a.text.align_pt.map(|p| p.xy()),
a.text.height,
a.text.rotation,
a.text.width_factor,
a.text.oblique,
h,
v,
);
for s in strokes {
b.polyline(ctx, rgb, 0.0, &s);
}
let (sh, _) = place_text_entity(ctx.d, &a.text, &a.text.value);
b.shaped(ctx, rgb, 0.0, &sh);
}
}
}
+185
View File
@@ -183,3 +183,188 @@ fn hostile_viewports_do_not_panic() {
let _ = build(&d, &Space::Paper("Layout1".into()), &Options::default());
}
}
fn text_doc(style_font: &str, value: &str) -> Drawing {
let mut d = Drawing::new_imperial();
d.text_styles.push(cadcraft_doc::TextStyle { name: "TT".into(), font: style_font.into(), ..Default::default() });
d.add(
&Space::Model,
Common::default(),
EntityKind::Text(cadcraft_doc::Text {
insert: Vec3::ZERO,
align_pt: None,
height: 1.0,
value: value.into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "TT".into(),
halign: Default::default(),
valign: Default::default(),
}),
)
.unwrap();
d
}
/// The first common system TrueType font, if any is installed (tests skip otherwise).
fn system_font() -> Option<&'static str> {
["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find(|n| cadcraft_fonts::ttf::find(n).is_some())
}
#[test]
fn truetype_text_is_filled_or_outlined() {
let Some(font) = system_font() else { return };
let mut d = text_doc(font, "OH");
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.iter().any(|p| p.kind == Kind::Tris), "TEXTFILL=1 fills glyphs");
// The O's counter is a hole: filled area is well below the glyph box area.
let area: f64 = l
.prims
.iter()
.filter(|p| p.kind == Kind::Tris)
.flat_map(|p| l.points(p).chunks(3).map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()).collect::<Vec<_>>())
.sum();
assert!(area > 0.1 && area < l.bounds.width() * l.bounds.height() * 0.8, "area {area}");
assert!((l.bounds.max.y - 1.0).abs() < 0.1, "cap height = text height");
d.header.set_i64("TEXTFILL", 0);
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.iter().all(|p| p.kind == Kind::Polyline), "TEXTFILL=0 outlines");
assert!(l.prims.len() >= 3, "O has two contours, H one");
}
#[test]
fn missing_font_falls_back_to_stroke() {
let d = text_doc("NoSuchFontAnywhere.ttf", "OH");
let l = build(&d, &Space::Model, &Options::default());
assert!(!l.prims.is_empty() && l.prims.iter().all(|p| p.kind == Kind::Polyline));
}
#[test]
fn backwards_and_upside_down_mirror_text() {
let mut d = text_doc("CADCraft Stroke", "ABC");
let normal = build(&d, &Space::Model, &Options::default()).bounds;
assert!(normal.min.x >= -1e-9 && normal.min.y >= -1e-9);
d.text_styles.iter_mut().for_each(|s| s.backwards = true);
let back = build(&d, &Space::Model, &Options::default()).bounds;
assert!(back.max.x <= 1e-9 && (back.width() - normal.width()).abs() < 1e-9, "mirrored to the left");
d.text_styles.iter_mut().for_each(|s| {
s.backwards = false;
s.upside_down = true;
});
let up = build(&d, &Space::Model, &Options::default()).bounds;
assert!(up.max.y <= 1e-9 && up.min.y < -0.9, "mirrored below the baseline");
}
#[test]
fn truetype_mtext_and_dimension_text() {
let Some(font) = system_font() else { return };
let mut d = Drawing::new_imperial();
d.text_styles.iter_mut().for_each(|s| s.font = font.into());
d.add(
&Space::Model,
Common::default(),
EntityKind::MText(cadcraft_doc::MText {
insert: Vec3::ZERO,
height: 1.0,
width: 0.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: "A{\\C1;B}\\P\\S1/2;".into(),
line_spacing: 1.0,
}),
)
.unwrap();
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.iter().any(|p| p.kind == Kind::Tris && p.color == Rgb(255, 0, 0)), "red B");
assert!(l.prims.iter().any(|p| p.kind == Kind::Tris && p.color != Rgb(255, 0, 0)));
let mut d = Drawing::new_imperial();
d.text_styles.iter_mut().for_each(|s| s.font = font.into());
d.add(&Space::Model, Common::default(), EntityKind::Dimension(lin_dim())).unwrap();
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.iter().filter(|p| p.kind == Kind::Tris).count() > 2, "arrows plus filled glyphs");
}
fn lin_dim() -> cadcraft_doc::Dimension {
cadcraft_doc::Dimension {
kind: cadcraft_doc::DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(0.0, 2.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
}
}
#[test]
fn dimension_colours_by_role() {
let mut d = Drawing::new_imperial();
if let Some(st) = d.dim_styles.first_mut() {
st.dim_line_color = Color::Index(1);
st.ext_line_color = Color::Index(3);
st.text_color = Color::Index(5);
}
d.add(&Space::Model, Common::default(), EntityKind::Dimension(lin_dim())).unwrap();
let l = build(&d, &Space::Model, &Options::default());
let has = |c: Rgb, k: Kind| l.prims.iter().any(|p| p.color == c && p.kind == k);
assert!(has(Rgb(255, 0, 0), Kind::Polyline) && has(Rgb(255, 0, 0), Kind::Tris), "dimension line and arrows red");
assert!(has(Rgb(0, 255, 0), Kind::Polyline), "extension lines green");
assert!(has(Rgb(0, 0, 255), Kind::Polyline), "text blue");
// ByBlock (the default) takes the dimension's own colour.
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common { color: Color::Index(6), ..Default::default() }, EntityKind::Dimension(lin_dim())).unwrap();
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.iter().all(|p| p.color == Rgb(255, 0, 255)));
}
#[test]
fn table_title_is_centred_and_merged() {
let mut d = Drawing::new_imperial();
let cell = |t: &str| cadcraft_doc::TableCell { text: t.into(), merged: None };
let mut title = vec![cell("TITLE"), cell(""), cell("")];
title[0].merged = Some((1, 3));
let t = cadcraft_doc::Table {
insert: Vec3::new(0.0, 0.0, 0.0),
col_widths: vec![2.0, 2.0, 2.0],
row_heights: vec![1.0, 1.0],
cells: vec![title, vec![cell("a"), cell("b"), cell("c")]],
style: "Standard".into(),
text_height: 0.2,
title: true,
header: false,
};
d.add(&Space::Model, Common::default(), EntityKind::Table(t)).unwrap();
let l = build(&d, &Space::Model, &Options::default());
// No vertical cell borders cross the merged title row (y in 0..-1) except the outer ones.
let inner_vertical_in_title = l.prims.iter().filter(|p| p.kind == Kind::Polyline).any(|p| {
l.points(p).windows(2).any(|w| {
(w[0].x - w[1].x).abs() < 1e-12
&& (w[0].y - w[1].y).abs() > 0.5
&& w[0].x > 0.1
&& w[0].x < 5.9
&& w[0].y.max(w[1].y) > -0.99
&& w[0].y.min(w[1].y) < -0.01
})
});
assert!(!inner_vertical_in_title);
// Title text centred on the table width.
let text: Vec<Vec2> = l
.prims
.iter()
.filter(|p| p.kind == Kind::Polyline)
.flat_map(|p| l.points(p).to_vec())
.filter(|q| q.y > -0.95 && q.y < -0.05 && q.x > 0.05 && q.x < 5.95)
.collect();
let b = Bounds2::from_points(text);
assert!((b.center().x - 3.0).abs() < 0.05, "{b:?}");
}
+294
View File
@@ -0,0 +1,294 @@
//! Dimension number formatting: DIMLUNIT (scientific, decimal, engineering, architectural,
//! fractional), DIMDEC precision, DIMZIN zero suppression, DIMRND rounding, DIMDSEP and
//! DIMFRAC fraction stacking; DIMAUNIT angles. Each formatter returns the MTEXT form (stacked
//! fractions as `\S1/2;`) that the dimension text is laid out from.
use cadcraft_doc::DimStyle;
/// Linear number format settings.
#[derive(Clone, Debug, PartialEq)]
pub struct NumFormat {
/// DIMLUNIT: 1 scientific, 2 decimal, 3 engineering, 4 architectural, 5 fractional, 6 decimal.
pub unit: u8,
/// DIMDEC: decimal places, or the fraction precision 1/2^n for architectural/fractional.
pub decimals: u8,
/// DIMZIN bits: low two bits feet/inch zeros, 4 leading zeros, 8 trailing zeros.
pub zin: u8,
/// DIMRND (0 = none).
pub round: f64,
pub separator: String,
/// DIMFRAC: 0 horizontal stack, 1 diagonal, 2 not stacked.
pub frac: u8,
}
impl NumFormat {
pub fn decimal(decimals: u8) -> Self {
NumFormat { unit: 2, decimals, zin: 0, round: 0.0, separator: ".".into(), frac: 0 }
}
pub fn from_style(st: &DimStyle) -> Self {
NumFormat {
unit: st.linear_unit,
decimals: st.decimals,
zin: st.zero_suppression,
round: st.round,
separator: st.decimal_separator.clone(),
frac: st.fraction_format,
}
}
}
/// Decimal number with leading (`zin & 4`) / trailing (`zin & 8`) zero suppression.
pub fn format_decimal(v: f64, decimals: u8, zin: u8, sep: &str) -> String {
let v = if v.is_finite() { v } else { 0.0 };
let mut s = format!("{:.*}", usize::from(decimals.min(8)), v);
if s.starts_with('-') && s.trim_start_matches(['-', '0', '.']).is_empty() {
s.remove(0);
}
if zin & 8 != 0 && s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if zin & 4 != 0 {
if let Some(r) = s.strip_prefix("0.") {
s = format!(".{r}");
} else if let Some(r) = s.strip_prefix("-0.") {
s = format!("-.{r}");
}
}
if s.is_empty() || s == "-" {
s = "0".into();
}
if sep != "." && !sep.is_empty() { s.replace('.', sep) } else { s }
}
fn gcd(a: i64, b: i64) -> i64 {
let (mut a, mut b) = (a.abs(), b.abs());
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a.max(1)
}
/// A fraction in MTEXT form per DIMFRAC.
fn frac_mtext(num: i64, den: i64, frac: u8) -> (String, String) {
let plain = format!("{num}/{den}");
let m = match frac {
1 => format!("\\S{num}#{den};"),
2 => plain.clone(),
_ => format!("\\S{num}/{den};"),
};
(plain, m)
}
/// Scientific: `1.2345E+01`.
fn scientific(v: f64, decimals: u8, zin: u8, sep: &str) -> String {
if v == 0.0 || !v.is_finite() {
return format!("{}E+00", format_decimal(0.0, decimals, zin & 8, sep));
}
let mut exp = v.abs().log10().floor() as i32;
let mut mant = v / 10f64.powi(exp);
// Rounding can carry the mantissa to 10.
let p = 10f64.powi(i32::from(decimals.min(8)));
if (mant.abs() * p).round() / p >= 10.0 {
mant /= 10.0;
exp += 1;
}
format!("{}E{}{:02}", format_decimal(mant, decimals, zin & 8, sep), if exp < 0 { '-' } else { '+' }, exp.abs())
}
/// Apply feet/inch zero suppression (DIMZIN low bits) and join the parts.
fn feet_inches(neg: bool, ft: i64, inch_plain: &str, inch_m: &str, inch_zero: bool, zin: u8) -> (String, String) {
let mode = zin & 3;
let show_ft = ft != 0 || mode == 1 || mode == 2;
let show_in = !inch_zero || mode == 1 || mode == 3 || (ft == 0 && !show_ft);
let sign = if neg { "-" } else { "" };
let mut p = String::from(sign);
let mut m = String::from(sign);
if show_ft {
p += &format!("{ft}'");
m += &format!("{ft}'");
if show_in {
p.push('-');
m.push('-');
}
}
if show_in {
p += &format!("{inch_plain}\"");
m += &format!("{inch_m}\"");
}
(p, m)
}
/// Whole number plus a fraction of 1/2^prec, in plain and MTEXT form.
fn mixed(a: f64, prec: u8, frac: u8) -> (i64, String, String, bool) {
let den = 1i64 << prec.min(8);
let total = (a * den as f64).round().clamp(-9.0e15, 9.0e15) as i64;
let whole = total / den;
let rem = total % den;
if rem == 0 {
return (whole, whole.to_string(), whole.to_string(), whole == 0);
}
let g = gcd(rem, den);
let (fp, fm) = frac_mtext(rem / g, den / g, frac);
let (p, m) = if whole == 0 {
(fp, fm)
} else if frac == 2 {
(format!("{whole} {fp}"), format!("{whole} {fm}"))
} else {
(format!("{whole} {fp}"), format!("{whole}{fm}"))
};
(whole, p, m, false)
}
/// Format a linear distance. Returns (plain text, MTEXT form).
pub fn format_linear(v: f64, f: &NumFormat) -> (String, String) {
let mut v = if v.is_finite() { v } else { 0.0 };
if f.round > 0.0 && f.round.is_finite() {
v = (v / f.round).round() * f.round;
}
let sep = f.separator.as_str();
match f.unit {
1 => {
let s = scientific(v, f.decimals, f.zin, sep);
(s.clone(), s)
}
3 => {
// Engineering: feet and decimal inches.
let neg = v < 0.0;
let a = v.abs();
let p = 10f64.powi(i32::from(f.decimals.min(8)));
let a = (a * p).round() / p;
let ft = (a / 12.0 + 1e-12).floor();
let inch = (a - ft * 12.0).max(0.0);
let s = format_decimal(inch, f.decimals, f.zin & 12, sep);
feet_inches(neg, ft as i64, &s, &s, inch.abs() < 0.5 / p, f.zin)
}
4 => {
// Architectural: feet, inches and fractions.
let neg = v < 0.0;
let den = 1i64 << f.decimals.min(8);
let total = (v.abs() * den as f64).round().clamp(0.0, 9.0e15) as i64;
let ft = total / (12 * den);
let rem_inch = (total - ft * 12 * den) as f64 / den as f64;
let (_, ip, im, zero) = mixed(rem_inch, f.decimals, f.frac);
feet_inches(neg, ft, &ip, &im, zero, f.zin)
}
5 => {
let neg = v < 0.0;
let (_, p, m, _) = mixed(v.abs(), f.decimals, f.frac);
let sign = if neg && p != "0" { "-" } else { "" };
(format!("{sign}{p}"), format!("{sign}{m}"))
}
_ => {
let s = format_decimal(v, f.decimals, f.zin, sep);
(s.clone(), s)
}
}
}
/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC.
pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String {
let rad = if rad.is_finite() { rad } else { 0.0 };
let deg = rad.to_degrees();
match unit {
1 => {
let total = (deg * 3600.0).round().max(0.0) as i64;
let (d, m, s) = (total / 3600, (total / 60) % 60, total % 60);
match decimals {
0 => format!("{d}°"),
1 | 2 => format!("{d}°{m}'"),
_ => format!("{d}°{m}'{s}\""),
}
}
2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, ".")),
3 => format!("{}r", format_decimal(rad, decimals, zin, ".")),
_ => format!("{}°", format_decimal(deg, decimals, zin, ".")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn f(unit: u8, decimals: u8, zin: u8) -> NumFormat {
NumFormat { unit, decimals, zin, ..NumFormat::decimal(decimals) }
}
#[test]
fn decimal_and_zero_suppression() {
assert_eq!(format_linear(0.5, &f(2, 4, 0)).0, "0.5000");
assert_eq!(format_linear(0.5, &f(2, 4, 4)).0, ".5000");
assert_eq!(format_linear(0.5, &f(2, 4, 8)).0, "0.5");
assert_eq!(format_linear(0.5, &f(2, 4, 12)).0, ".5");
assert_eq!(format_linear(12.0, &f(2, 2, 8)).0, "12");
assert_eq!(format_linear(-0.00001, &f(2, 2, 0)).0, "0.00");
let mut c = f(2, 2, 0);
c.separator = ",".into();
assert_eq!(format_linear(12.5, &c).0, "12,50");
}
#[test]
fn rounding() {
let mut c = f(2, 2, 0);
c.round = 0.25;
assert_eq!(format_linear(1.13, &c).0, "1.25");
}
#[test]
fn scientific_units() {
assert_eq!(format_linear(12.345, &f(1, 2, 0)).0, "1.23E+01");
assert_eq!(format_linear(0.05, &f(1, 1, 0)).0, "5.0E-02");
assert_eq!(format_linear(9.999, &f(1, 2, 0)).0, "1.00E+01");
}
#[test]
fn engineering_units() {
assert_eq!(format_linear(18.5, &f(3, 2, 0)).0, "1'-6.50\"");
assert_eq!(format_linear(6.0, &f(3, 2, 0)).0, "6.00\"");
assert_eq!(format_linear(6.0, &f(3, 2, 1)).0, "0'-6.00\"");
}
#[test]
fn architectural_units_and_stacking() {
let (p, m) = format_linear(18.5, &f(4, 4, 0));
assert_eq!(p, "1'-6 1/2\"");
assert_eq!(m, "1'-6\\S1/2;\"");
assert_eq!(format_linear(24.0, &f(4, 4, 0)).0, "2'");
assert_eq!(format_linear(24.0, &f(4, 4, 1)).0, "2'-0\"");
assert_eq!(format_linear(24.0, &f(4, 4, 3)).0, "2'-0\"");
assert_eq!(format_linear(6.0, &f(4, 4, 0)).0, "6\"");
assert_eq!(format_linear(6.0, &f(4, 4, 2)).0, "0'-6\"");
assert_eq!(format_linear(0.25, &f(4, 4, 0)).0, "1/4\"");
assert_eq!(format_linear(0.0, &f(4, 4, 0)).0, "0\"");
let mut d = f(4, 4, 0);
d.frac = 1;
assert_eq!(format_linear(18.5, &d).1, "1'-6\\S1#2;\"");
}
#[test]
fn fractional_units() {
assert_eq!(format_linear(6.5, &f(5, 4, 0)).0, "6 1/2");
assert_eq!(format_linear(6.5, &f(5, 4, 0)).1, "6\\S1/2;");
assert_eq!(format_linear(6.0, &f(5, 4, 0)).0, "6");
assert_eq!(format_linear(0.375, &f(5, 3, 0)).0, "3/8");
assert_eq!(format_linear(0.4, &f(5, 2, 0)).0, "1/2");
}
#[test]
fn angles() {
assert_eq!(format_angle(std::f64::consts::FRAC_PI_4, 0, 0, 0), "45°");
assert_eq!(format_angle(45.5f64.to_radians(), 1, 4, 0), "45°30'0\"");
assert_eq!(format_angle(std::f64::consts::FRAC_PI_2, 2, 1, 0), "100.0g");
assert_eq!(format_angle(1.0, 3, 2, 0), "1.00r");
}
#[test]
fn hostile_numbers() {
for u in 0..8 {
for v in [f64::NAN, f64::INFINITY, -1e300, 1e300, 0.0, -0.0] {
let _ = format_linear(v, &f(u, 200, 255));
}
}
}
}
+16 -1
View File
@@ -263,8 +263,23 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
cadcraft_engine::units::format_distance(0.0, lu, lp)
)
});
// MODEL / PAPER (layouts only): toggles MSPACE and PSPACE like AutoCAD's status button.
let mut coord_right = rx - 8.0;
if matches!(app.session.layout_space(), cadcraft_doc::Space::Paper(_)) {
let model = app.session.state().is_ok_and(|st| st.mspace.is_some());
let label = if model { "MODEL" } else { "PAPER" };
let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0));
let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click());
p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) });
p.text(br.center(), egui::Align2::CENTER_CENTER, label, crate::theme::small(), t.text);
if resp.on_hover_text("Switch between model space in a viewport and paper space").clicked() {
let _ = app.run(if model { "pspace" } else { "mspace" }, json!({}));
app.canvas.list = None;
}
coord_right = br.left() - 10.0;
}
if let Some(c) = coord {
p.text(pos2(rx - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, c, crate::theme::body(), t.text);
p.text(pos2(coord_right, r.center().y), egui::Align2::RIGHT_CENTER, c, crate::theme::body(), t.text);
}
let _ = coords_w;
let mut toggle_cmd = None;
+5 -34
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see ROADMAP.md for the weighted estimate.
**Menu breadth: 204 / 491 items (42%)** · 227 registered commands.
**Menu breadth: 233 / 491 items (47%)** · 290 registered commands.
| Menu | Items | Covered | % |
|---|---|---|---|
@@ -10,10 +10,10 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
| Edit | 13 | 10 | 77% |
| View | 75 | 27 | 36% |
| Insert | 11 | 2 | 18% |
| Format | 27 | 21 | 78% |
| Tools | 85 | 23 | 27% |
| Draw | 87 | 54 | 62% |
| Dimension | 28 | 19 | 68% |
| Format | 27 | 23 | 85% |
| Tools | 85 | 46 | 54% |
| Draw | 87 | 55 | 63% |
| Dimension | 28 | 22 | 79% |
| Modify | 113 | 37 | 33% |
| Window | 22 | 2 | 9% |
| Help | 8 | 0 | 0% |
@@ -95,8 +95,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Insert > Reference Manager
- Format > Transparency
- Format > Scale List...
- Format > Table Style...
- Format > Multileader Style...
- Format > Plot Style...
- Format > Thickness
- Tools > Spelling
@@ -116,29 +114,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Tools > Edit Xref in Place
- Tools > Open Xref
- Tools > Load Application...
- Tools > Parametric > Geometric Constraints > Coincident
- Tools > Parametric > Geometric Constraints > Perpendicular
- Tools > Parametric > Geometric Constraints > Parallel
- Tools > Parametric > Geometric Constraints > Tangent
- Tools > Parametric > Geometric Constraints > Horizontal
- Tools > Parametric > Geometric Constraints > Vertical
- Tools > Parametric > Geometric Constraints > Collinear
- Tools > Parametric > Geometric Constraints > Concentric
- Tools > Parametric > Geometric Constraints > Smooth
- Tools > Parametric > Geometric Constraints > Symmetric
- Tools > Parametric > Geometric Constraints > Equal
- Tools > Parametric > Geometric Constraints > Fix
- Tools > Parametric > AutoConstrain
- Tools > Parametric > Constraint Bars > Select Objects
- Tools > Parametric > Constraint Bars > Show All
- Tools > Parametric > Constraint Bars > Hide All
- Tools > Parametric > Dimensional Constraints > Horizontal
- Tools > Parametric > Dimensional Constraints > Vertical
- Tools > Parametric > Dynamic Dimensions > Select Objects
- Tools > Parametric > Dynamic Dimensions > Show All
- Tools > Parametric > Dynamic Dimensions > Hide All
- Tools > Parametric > Delete Constraints
- Tools > Parametric > Constraint Settings
- Tools > New UCS > World
- Tools > New UCS > Face
- Tools > New UCS > View
@@ -193,16 +168,12 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Draw > 3D Modeling > Setup > Drawing
- Draw > 3D Modeling > Setup > View
- Draw > 3D Modeling > Setup > Profile
- Draw > Table...
- Dimension > Jogged
- Dimension > Dimension Space
- Dimension > Dimension Break
- Dimension > Tolerance...
- Dimension > Inspection
- Dimension > Jogged Linear
- Dimension > Oblique
- Dimension > Align Text > Home
- Dimension > Reassociate Dimensions
- Modify > Object > External Reference > Bind...
- Modify > Object > External Reference > Frame
- Modify > Object > Image > Adjust...
+1
View File
@@ -38,6 +38,7 @@ pub const TABLE: &[(&str, Class)] = &[
("dwg", Class::Standalone),
("color", Class::Layer(0)),
("doc", Class::Layer(1)),
("constraints", Class::Layer(2)),
("fonts", Class::Layer(2)),
("render", Class::Layer(2)),
("io", Class::Layer(3)),