mirror of
https://github.com/aphrody-labs/cadcraft.git
synced 2026-10-13 19:26:54 +00:00
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:
1 parent
c35fa82373
commit
f9aa1b279a
42 files changed
+8479
-412
No files matched your search
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +479,9 @@ 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> {
|
||||
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()?;
|
||||
@@ -375,9 +493,295 @@ fn run_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
}
|
||||
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, 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.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;
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
+561
-36
@@ -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).
|
||||
/// 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 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 {
|
||||
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;
|
||||
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(' ');
|
||||
}
|
||||
}
|
||||
lines.push(cur);
|
||||
} else {
|
||||
lines.push(para.to_string());
|
||||
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;
|
||||
}
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+109
-37
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// 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();
|
||||
}
|
||||
pen.dx += f64::from(gm.advance_width(gid).unwrap_or(upem * 0.5)) * scale * wf;
|
||||
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();
|
||||
}
|
||||
Some(Run { strokes: pen.contours, width: pen.dx })
|
||||
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 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 })
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -90,6 +90,8 @@ fn sample() -> Drawing {
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -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 }
|
||||
+816
-100
File diff suppressed because it is too large.
Load diff
+224
-101
@@ -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, ¶ms)
|
||||
}
|
||||
|
||||
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, ¶ms);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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...
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user