M1/M2/M6/M10: draw/modify depth, grips API, layouts/viewports/PLOT to PDF, GPU canvas, R-tree index

- All 2D Draw-menu items: ARC variants, CIRCLE TTR/TTT, DIVIDE/MEASURE, REVCLOUD, WIPEOUT, 3DPOLY,
  MLINE, HELIX, SPLINE CV, CENTERMARK/CENTERLINE, XLINE and RECTANG options.
- PEDIT, SPLINEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, NCOPY, CHSPACE, FLATTEN, ARRAYPATH, TEXTEDIT;
  FILLET with arcs and polylines; TRIM ellipses and splines; grip editing commands.
- Layouts, MVIEW/VPORTS, viewport scale/lock/layers, PAGESETUP, PLOT/EXPORTPDF with our own PDF
  writer; paper-space rendering through viewports.
- GPU canvas (wgpu batches, CPU fallback); rstar spatial index for pick/select/osnap;
  cadcraft-cli perf harness.
- CAL, FIND/replace, MASSPROP, MEASUREGEOM; TrueType outlines in cadcraft-fonts; contributors
  standard (xtask contributors, About window credits); docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-06 00:40:03 -04:00
co-authored by Claude Opus 5.5
parent 4c74c5591c
commit 3da0219ac9
54 changed files with 11914 additions and 345 deletions
Generated
+61 -4
View File
@@ -601,6 +601,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"log",
"rstar",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -611,6 +612,7 @@ name = "cadcraft-fonts"
version = "0.1.0"
dependencies = [
"cadcraft-geom",
"skrifa 0.47.0",
]
[[package]]
@@ -630,6 +632,8 @@ dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.8.9",
"serde_json",
"thiserror 2.0.21",
]
@@ -665,8 +669,10 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"egui",
"egui-wgpu",
"image",
"log",
"naga",
"serde",
"serde_json",
]
@@ -1173,7 +1179,7 @@ dependencies = [
"parking_lot",
"profiling",
"self_cell",
"skrifa",
"skrifa 0.44.0",
"smallvec",
"unicode-general-category",
"unicode-segmentation",
@@ -1442,7 +1448,7 @@ dependencies = [
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa",
"skrifa 0.44.0",
"smallvec",
"vello_common",
]
@@ -1568,10 +1574,19 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"read-fonts",
"read-fonts 0.41.0",
"smallvec",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1592,6 +1607,16 @@ dependencies = [
"foldhash",
]
[[package]]
name = "heapless"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2845,6 +2870,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "read-fonts"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -2905,6 +2941,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rstar"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
dependencies = [
"heapless",
"num-traits",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3111,7 +3158,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts",
"read-fonts 0.41.0",
]
[[package]]
name = "skrifa"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
dependencies = [
"bytemuck",
"read-fonts 0.44.0",
]
[[package]]
+12 -12
View File
@@ -8,9 +8,9 @@ agent control over MCP, a scriptable CLI, a web build and a free licence.
| Measure | Value |
|---|---|
| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **119 / 491 reference menu items (24%)** |
| Registered commands | 130 (41 with interactive prompts) |
| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 12%** |
| 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%** |
| 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 |
@@ -24,24 +24,24 @@ interactive DIM commands yet).
| # | Milestone | Status | Estimate (Opus 5.5 wall-clock hours) |
|---|---|---|---|
| M0 | Skeleton + vertical slice: workspace, AutoCAD-style UI, command line, draw/modify basics, DXF, CLI, MCP, web | **done** | — |
| M1 | Drafting core: every Draw-menu 2D command and option, dynamic input fields, object snap tracking, temporary snap overrides, TTR/TTT circles, REVCLOUD, WIPEOUT, REGION, BOUNDARY | in progress (~60%) | 20 |
| 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 | ~45% | 30 |
| 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 |
| 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 | ~5% | 60 |
| M7 | Files: DWG read (R2000–2018), DWG write, DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~15% | 120 |
| 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 |
| 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 |
| M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~10% | 30 |
| 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** | | **≈ 790 hours** |
| | **Remaining total** | | **≈ 680 hours** |
At roughly 790 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to
about 200–250 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity
(M1–M8) is the first ≈ 410 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
(M1–M8) is the first ≈ 300 hours.
## Current focus
+138
View File
@@ -6,6 +6,7 @@
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png]
//! cadcraft-cli commands [FILTER] command catalog (JSON)
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -20,12 +21,14 @@ const USAGE: &str = "usage:
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg)]
cadcraft-cli commands [FILTER]
cadcraft-cli mcp [--connect HOST:PORT]
cadcraft-cli perf [N]
cadcraft-cli --version";
fn install_io() {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
}
@@ -103,6 +106,140 @@ fn run(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next()
}
}
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
/// over a square whose side grows with sqrt(n), so density stays constant.
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, String> {
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
let mut dr = d::Drawing::new_imperial();
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
for i in 0..n {
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
let s = r.range(0.5, 8.0);
let kind = match i % 5 {
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
3 => EntityKind::LwPolyline(d::LwPolyline {
vertices: (0..6)
.map(|k| PolyVertex {
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
bulge: if k == 2 { 0.4 } else { 0.0 },
start_width: 0.0,
end_width: 0.0,
})
.collect(),
closed: i % 2 == 0,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
_ => EntityKind::Text(d::Text {
insert: v3(x, y),
align_pt: None,
height: s * 0.2,
value: format!("T{i}"),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: d::HAlign::Left,
valign: d::VAlign::Baseline,
}),
};
dr.add(&Space::Model, Common::default(), kind).map_err(|e| e.to_string())?;
}
Ok(dr)
}
fn perf(args: &[String]) -> Result<(), String> {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
let n: usize = match args.first() {
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
None => 200_000,
};
let n = n.clamp(1, 20_000_000);
let side = (n as f64).sqrt() * 10.0;
let mut rows: Vec<(String, f64, String)> = Vec::new();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let d = synthetic(n, side)?;
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
let px = side / 1600.0;
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
let t = Instant::now();
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
let ap = 0.1;
let mut r = Rng(42);
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let t = Instant::now();
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
let t = Instant::now();
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
let total = ms(t);
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
let mut sel = 0usize;
let t = Instant::now();
for k in 0..20 {
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
let half = side * 0.05;
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
}
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
let t = Instant::now();
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
let t = Instant::now();
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None).is_some()).count();
let total = ms(t);
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
let t = Instant::now();
let bytes = cadcraft_io::write(&d, "perf.dxf").map_err(|e| e.to_string())?;
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
let t = Instant::now();
let back = cadcraft_io::read(&bytes, "perf.dxf").map_err(|e| e.to_string())?;
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
println!("{:<40} {:>12} notes", "operation", "ms");
println!("{}", "-".repeat(80));
for (name, t, note) in rows {
println!("{name:<40} {t:>12.2} {note}");
}
Ok(())
}
fn main() -> ExitCode {
install_io();
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -117,6 +254,7 @@ fn main() -> ExitCode {
_ => Err(USAGE.into()),
},
Some("run") => run(&rest),
Some("perf") => perf(&rest),
Some("commands") => {
let s = Session::new();
let f = rest.first().map(|x| x.to_ascii_lowercase());
+5 -1
View File
@@ -35,6 +35,7 @@ mod web {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
@@ -52,8 +53,11 @@ mod web {
.start(
canvas,
options,
Box::new(move |_cc| {
Box::new(move |cc| {
let mut app = CadApp::new(Session::new(), Services::default());
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs);
}
if query().contains("sample") {
let _ = app.run("ui.sample", serde_json::json!({}));
}
+4
View File
@@ -46,6 +46,7 @@ pub fn install_io() {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
});
}
@@ -120,6 +121,9 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
let mut app = CadApp::new(Session::empty(), services());
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
+6
View File
@@ -0,0 +1,6 @@
sha url login date additions deletions pr models
7e4583b3406de501e48dce5e9accaade8b608e85 https://github.com/storytold/cadcraft/commit/7e4583b3406de501e48dce5e9accaade8b608e85 echelon 2026-10-07T02:15:47Z 7 0
70f0489e0ce753fdf46d03d59ea47f974bb0b26c https://github.com/storytold/cadcraft/commit/70f0489e0ce753fdf46d03d59ea47f974bb0b26c echelon 2026-10-07T05:22:25Z 30261 5 anthropic/claude-opus/5.5
1565bfd5c70760fcdd007874f9fa4ba5e6658549 https://github.com/storytold/cadcraft/commit/1565bfd5c70760fcdd007874f9fa4ba5e6658549 echelon 2026-10-07T05:31:44Z 2191 44 anthropic/claude-opus/5.5
125803d25823a1eb1f14ea46dfdb617d08bc27b8 https://github.com/storytold/cadcraft/commit/125803d25823a1eb1f14ea46dfdb617d08bc27b8 echelon 2026-10-07T05:43:22Z 236 6 anthropic/claude-opus/5.5
e68e81a89ab7752cacd32acb0c93020b739b3624 https://github.com/storytold/cadcraft/commit/e68e81a89ab7752cacd32acb0c93020b739b3624 echelon 2026-10-07T05:50:51Z 1714 5 anthropic/claude-opus/5.5
1 sha url login date additions deletions pr models
2 7e4583b3406de501e48dce5e9accaade8b608e85 https://github.com/storytold/cadcraft/commit/7e4583b3406de501e48dce5e9accaade8b608e85 echelon 2026-10-07T02:15:47Z 7 0
3 70f0489e0ce753fdf46d03d59ea47f974bb0b26c https://github.com/storytold/cadcraft/commit/70f0489e0ce753fdf46d03d59ea47f974bb0b26c echelon 2026-10-07T05:22:25Z 30261 5 anthropic/claude-opus/5.5
4 1565bfd5c70760fcdd007874f9fa4ba5e6658549 https://github.com/storytold/cadcraft/commit/1565bfd5c70760fcdd007874f9fa4ba5e6658549 echelon 2026-10-07T05:31:44Z 2191 44 anthropic/claude-opus/5.5
5 125803d25823a1eb1f14ea46dfdb617d08bc27b8 https://github.com/storytold/cadcraft/commit/125803d25823a1eb1f14ea46dfdb617d08bc27b8 echelon 2026-10-07T05:43:22Z 236 6 anthropic/claude-opus/5.5
6 e68e81a89ab7752cacd32acb0c93020b739b3624 https://github.com/storytold/cadcraft/commit/e68e81a89ab7752cacd32acb0c93020b739b3624 echelon 2026-10-07T05:50:51Z 1714 5 anthropic/claude-opus/5.5
+6
View File
@@ -0,0 +1,6 @@
# Maps Co-Authored-By trailer names to AI models.
id company model version trailer
anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5
anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5 (1M context)
anthropic/claude-sonnet/5.5 Anthropic Claude Sonnet 5.5 Claude Sonnet 5.5
anthropic/claude-fable/5.1 Anthropic Claude Fable 5.1 Claude Fable 5.1
1 # Maps Co-Authored-By trailer names to AI models.
2 id company model version trailer
3 anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5
4 anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5 (1M context)
5 anthropic/claude-sonnet/5.5 Anthropic Claude Sonnet 5.5 Claude Sonnet 5.5
6 anthropic/claude-fable/5.1 Anthropic Claude Fable 5.1 Claude Fable 5.1
+3
View File
@@ -0,0 +1,3 @@
# Consent and opt-in fields (craftrules standards/contributors.md). Never written by xtask.
login consent consent_date consent_source display_name real_name email
echelon yes 2026-10-06 owner Brandon Thomas Brandon Thomas
1 # Consent and opt-in fields (craftrules standards/contributors.md). Never written by xtask.
2 login consent consent_date consent_source display_name real_name email
3 echelon yes 2026-10-06 owner Brandon Thomas Brandon Thomas
+1
View File
@@ -0,0 +1 @@
number url login merged_at additions deletions commits models
1 number url login merged_at additions deletions commits models
+5
View File
@@ -189,6 +189,11 @@ impl EntityStore {
None => false,
}
}
/// True when `other` shares every chunk with `self` (same draw order, same entities). Holding
/// a clone of a store makes this exact: any edit to the original copies the touched chunk.
pub fn same_as(&self, other: &EntityStore) -> bool {
self.len == other.len && self.chunks.len() == other.chunks.len() && self.chunks.iter().zip(&other.chunks).all(|(a, b)| Arc::ptr_eq(a, b))
}
/// The last entity in draw order (AutoCAD's "Last" selection).
pub fn last(&self) -> Option<&Arc<Entity>> {
self.chunks.last().and_then(|c| c.items.last())
+2
View File
@@ -18,3 +18,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
rstar = { version = "0.12", default-features = false }
+761
View File
@@ -0,0 +1,761 @@
//! Shared curve geometry for the M1/M2 draw and modify commands: arc constructions, tangent
//! circles (TTR/TTT), length parametrisation of curves, spline splitting and picking helpers.
use cadcraft_doc::{Entity, EntityKind, Handle, Prim};
use cadcraft_geom::{Arc, Circle, EPS, Line, PolyVertex, Polyline, Segment, Spline, TAU, Vec2, circle_circle, line_circle, line_line_infinite};
use crate::{EngineError, Result, Session};
/// Hard cap on generated vertices/points from user-sized inputs.
pub const MAX_GEN: usize = 20_000;
/// A keyword-only prompt: spaces separate inputs (like Enter), as at AutoCAD's option prompts.
pub const KW: crate::Accept = crate::Accept { point: false, number: false, text: false, select: false, enter: true };
// ---------------- picking ----------------
pub fn pick_aperture(s: &Session) -> f64 {
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
}
/// The object under `p` in the current space.
pub fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
let space = s.space();
crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s))
}
pub fn entity(s: &Session, h: Handle) -> Result<Entity> {
s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))
}
pub fn handle_value(v: &serde_json::Value) -> Option<Handle> {
v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))
}
pub fn handle_param(p: &serde_json::Value, key: &str) -> Option<Handle> {
p.get(key).and_then(handle_value)
}
/// Is the entity on a locked layer?
pub fn is_locked(s: &Session, h: Handle) -> bool {
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
}
// ---------------- arc constructions ----------------
fn finite_arc(a: Arc) -> Option<Arc> {
(a.center.is_finite() && a.radius.is_finite() && a.radius > EPS && a.start.is_finite() && a.end.is_finite()).then_some(a)
}
/// Arc from start, center and a signed included angle (CCW positive).
pub fn arc_sca(st: Vec2, c: Vec2, angle: f64) -> Option<Arc> {
let r = st.dist(c);
if !angle.is_finite() || angle.abs() < 1e-12 {
return None;
}
let a0 = c.angle_to(st);
let a = angle.clamp(-TAU, TAU);
finite_arc(if a > 0.0 { Arc::new(c, r, a0, a0 + a) } else { Arc::new(c, r, a0 + a, a0) })
}
/// Arc from start, center and chord length (positive: CCW minor arc, negative: CCW major arc).
pub fn arc_scl(st: Vec2, c: Vec2, chord: f64) -> Option<Arc> {
let r = st.dist(c);
if r <= EPS || !chord.is_finite() || chord.abs() < 1e-12 || chord.abs() > 2.0 * r + 1e-9 {
return None;
}
let theta = 2.0 * (chord.abs() / (2.0 * r)).clamp(-1.0, 1.0).asin();
let sweep = if chord > 0.0 { theta } else { TAU - theta };
arc_sca(st, c, sweep)
}
/// Arc from start and end points with a signed included angle (CCW positive).
pub fn arc_sea(st: Vec2, en: Vec2, angle: f64) -> Option<Arc> {
if !angle.is_finite() || angle.abs() < 1e-9 || angle.abs() >= TAU - 1e-9 || st.near(en, EPS) {
return None;
}
let (a, b, th) = if angle > 0.0 { (st, en, angle) } else { (en, st, -angle) };
let chord = a.dist(b);
let r = chord / (2.0 * (th / 2.0).sin());
let h = (chord / 2.0) / (th / 2.0).tan();
let c = a.mid(b) + (b - a).normalized().perp() * h;
finite_arc(Arc::new(c, r.abs(), c.angle_to(a), c.angle_to(b)))
}
/// Arc from start and end points with the tangent direction at the start.
pub fn arc_sed(st: Vec2, en: Vec2, dir: Vec2) -> Option<Arc> {
let d = dir.normalized();
if d == Vec2::ZERO || st.near(en, EPS) {
return None;
}
let n = d.perp();
let v = en - st;
let den = 2.0 * n.dot(v);
if den.abs() < 1e-12 * v.len().max(1.0) {
return None;
}
let t = v.len2() / den;
let c = st + n * t;
let r = c.dist(st);
finite_arc(if t > 0.0 { Arc::new(c, r, c.angle_to(st), c.angle_to(en)) } else { Arc::new(c, r, c.angle_to(en), c.angle_to(st)) })
}
/// Arc from start and end points with a radius (CCW; negative radius gives the major arc).
pub fn arc_ser(st: Vec2, en: Vec2, radius: f64) -> Option<Arc> {
let r = radius.abs();
let chord = st.dist(en);
if !radius.is_finite() || chord < EPS || r < chord / 2.0 - 1e-9 {
return None;
}
let h = (r * r - chord * chord / 4.0).max(0.0).sqrt();
let left = (en - st).normalized().perp();
let c = if radius > 0.0 { st.mid(en) + left * h } else { st.mid(en) - left * h };
finite_arc(Arc::new(c, r, c.angle_to(st), c.angle_to(en)))
}
/// End point and outgoing unit tangent of the last line/arc/open polyline in the current space
/// (ARC "Continue" and LINE continuation).
pub fn last_curve_end(s: &Session) -> Option<(Vec2, Vec2)> {
let d = s.doc().ok()?;
let space = s.space();
let store = d.space(&space)?;
for e in store.iter().collect::<Vec<_>>().into_iter().rev() {
match &e.kind {
EntityKind::Line(l) => {
let dir = (l.b.xy() - l.a.xy()).normalized();
if dir != Vec2::ZERO {
return Some((l.b.xy(), dir));
}
}
EntityKind::Arc(a) => {
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
return Some((g.end_point(), Vec2::from_angle(g.end).perp()));
}
EntityKind::LwPolyline(p) if !p.closed => {
let segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments();
if let Some(sg) = segs.last() {
return Some((sg.end(), sg.tangent(1.0)));
}
}
_ => {}
}
}
None
}
/// Signed bulge of the arc from `a` through `m` to `b` (0 when collinear).
pub fn bulge_through(a: Vec2, m: Vec2, b: Vec2) -> f64 {
let Some(arc) = Arc::from_3_points(a, m, b) else { return 0.0 };
let sw = arc.sweep();
if arc.start_point().near(a, 1e-6 * arc.radius.max(1.0)) { cadcraft_geom::arc_to_bulge(sw) } else { cadcraft_geom::arc_to_bulge(-sw) }
}
/// Bulge of the arc from `a` to `b` leaving `a` along `tangent`.
pub fn bulge_from_tangent(a: Vec2, b: Vec2, tangent: Vec2) -> f64 {
let c = b - a;
let t = tangent.normalized();
if c.len() < EPS || t == Vec2::ZERO {
return 0.0;
}
let alpha = t.cross(c).atan2(t.dot(c));
cadcraft_geom::arc_to_bulge(2.0 * alpha)
}
// ---------------- tangent circles ----------------
/// A tangency target: an infinite line or a full circle.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TanObj {
Line(Line),
Circle(Circle),
}
impl TanObj {
/// From an entity and the pick point (polylines use the picked segment).
pub fn from_kind(k: &EntityKind, pick: Vec2) -> Option<TanObj> {
match k {
EntityKind::Line(l) => {
let ln = Line::new(l.a.xy(), l.b.xy());
(ln.len() > EPS).then_some(TanObj::Line(ln))
}
EntityKind::Circle(c) => Some(TanObj::Circle(Circle::new(c.center.xy(), c.radius))),
EntityKind::Arc(a) => Some(TanObj::Circle(Circle::new(a.center.xy(), a.radius))),
EntityKind::Ray(r) | EntityKind::XLine(r) => Some(TanObj::Line(Line::new(r.base.xy(), r.base.xy() + r.dir.xy()))),
EntityKind::LwPolyline(p) => {
let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments();
let sg = segs.iter().min_by(|a, b| a.closest(pick).dist(pick).total_cmp(&b.closest(pick).dist(pick)))?;
Some(match sg {
Segment::Line(l) => TanObj::Line(*l),
Segment::Arc { arc, .. } => TanObj::Circle(Circle::new(arc.center, arc.radius)),
})
}
_ => None,
}
}
/// The point where a circle `c` tangent to this object touches it.
pub fn touch(&self, c: &Circle) -> Vec2 {
match self {
TanObj::Line(l) => l.project(c.center),
TanObj::Circle(k) => {
let d = (c.center - k.center).normalized();
let d = if d == Vec2::ZERO { Vec2::X } else { d };
let p1 = k.center + d * k.radius;
let p2 = k.center - d * k.radius;
if (p1.dist(c.center) - c.radius).abs() <= (p2.dist(c.center) - c.radius).abs() { p1 } else { p2 }
}
}
}
/// Tangency residual of circle `(c, r)`: zero when tangent.
fn residual(&self, c: Vec2, r: f64) -> f64 {
match self {
TanObj::Line(l) => l.dist_inf(c) - r,
TanObj::Circle(k) => {
let d = c.dist(k.center);
(d - (k.radius + r)).abs().min((d - (k.radius - r).abs()).abs())
}
}
}
fn offsets(&self, r: f64) -> Vec<TanObj> {
match self {
TanObj::Line(l) => vec![TanObj::Line(l.offset(r)), TanObj::Line(l.offset(-r))],
TanObj::Circle(k) => {
let mut v = vec![TanObj::Circle(Circle::new(k.center, k.radius + r))];
if (k.radius - r).abs() > EPS {
v.push(TanObj::Circle(Circle::new(k.center, (k.radius - r).abs())));
}
v
}
}
}
}
trait InfDist {
fn dist_inf(&self, p: Vec2) -> f64;
}
impl InfDist for Line {
fn dist_inf(&self, p: Vec2) -> f64 {
self.project(p).dist(p)
}
}
fn intersect_tan(a: &TanObj, b: &TanObj) -> Vec<Vec2> {
match (a, b) {
(TanObj::Line(x), TanObj::Line(y)) => line_line_infinite(x.a, x.b, y.a, y.b).map(|r| vec![r.0]).unwrap_or_default(),
(TanObj::Line(l), TanObj::Circle(c)) | (TanObj::Circle(c), TanObj::Line(l)) => line_circle(l, c).into_iter().map(|x| x.0).collect(),
(TanObj::Circle(x), TanObj::Circle(y)) => circle_circle(x, y),
}
}
/// Score a candidate: how far its tangent points are from the picks.
fn score(c: &Circle, objs: &[(TanObj, Vec2)]) -> f64 {
objs.iter().map(|(o, p)| o.touch(c).dist(*p)).sum()
}
/// Circle of radius `r` tangent to two objects, nearest the pick points.
pub fn ttr(o1: TanObj, p1: Vec2, o2: TanObj, p2: Vec2, r: f64) -> Option<Circle> {
if !(r.is_finite() && r > EPS) {
return None;
}
let objs = [(o1, p1), (o2, p2)];
let mut best: Option<(f64, Circle)> = None;
for a in o1.offsets(r) {
for b in o2.offsets(r) {
for c in intersect_tan(&a, &b) {
let ci = Circle::new(c, r);
if !c.is_finite() || o1.residual(c, r).abs() > 1e-6 * r.max(1.0) || o2.residual(c, r).abs() > 1e-6 * r.max(1.0) {
continue;
}
let sc = score(&ci, &objs);
if best.is_none_or(|(b, _)| sc < b) {
best = Some((sc, ci));
}
}
}
}
best.map(|(_, c)| c)
}
/// Solve a 3×3 linear system (Cramer's rule).
fn solve3(m: [[f64; 3]; 3], b: [f64; 3]) -> Option<[f64; 3]> {
let det = |m: &[[f64; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
};
let d = det(&m);
if !d.is_finite() || d.abs() < 1e-14 {
return None;
}
let mut out = [0.0; 3];
for (k, o) in out.iter_mut().enumerate() {
let mut mk = m;
for (row, bv) in mk.iter_mut().zip(b.iter()) {
row[k] = *bv;
}
*o = det(&mk) / d;
}
Some(out)
}
/// A signed tangency equation f(x, y, r) = 0 with its gradient.
#[derive(Clone, Copy)]
enum Eq3 {
/// n·p + c = s r (unit normal n).
Line { n: Vec2, c: f64, s: f64 },
/// |p − k| = R + s r (s = ±1), or r − R when `enclose`.
Circle { k: Vec2, rr: f64, s: f64, enclose: bool },
}
impl Eq3 {
fn eval(&self, x: [f64; 3]) -> (f64, [f64; 3]) {
let p = Vec2::new(x[0], x[1]);
let r = x[2];
match *self {
Eq3::Line { n, c, s } => (n.dot(p) + c - s * r, [n.x, n.y, -s]),
Eq3::Circle { k, rr, s, enclose } => {
let d = p - k;
let l = d.len().max(1e-12);
let g = [d.x / l, d.y / l];
if enclose { (l - (r - rr), [g[0], g[1], -1.0]) } else { (l - (rr + s * r), [g[0], g[1], -s]) }
}
}
}
}
fn eq_variants(o: &TanObj) -> Vec<Eq3> {
match o {
TanObj::Line(l) => {
let n = (l.b - l.a).normalized().perp();
let c = -n.dot(l.a);
vec![Eq3::Line { n, c, s: 1.0 }, Eq3::Line { n, c, s: -1.0 }]
}
TanObj::Circle(k) => vec![
Eq3::Circle { k: k.center, rr: k.radius, s: 1.0, enclose: false },
Eq3::Circle { k: k.center, rr: k.radius, s: -1.0, enclose: false },
Eq3::Circle { k: k.center, rr: k.radius, s: 0.0, enclose: true },
],
}
}
/// Circle tangent to three objects, nearest the pick points (Newton on all sign combinations).
pub fn ttt(objs: [(TanObj, Vec2); 3]) -> Option<Circle> {
let [(o1, p1), (o2, p2), (o3, p3)] = objs;
let mut starts: Vec<[f64; 3]> = Vec::new();
let cen = (p1 + p2 + p3) / 3.0;
let rad = (cen.dist(p1) + cen.dist(p2) + cen.dist(p3)) / 3.0;
starts.push([cen.x, cen.y, rad.max(1e-3)]);
if let Some(c) = Circle::from_3_points(p1, p2, p3) {
starts.push([c.center.x, c.center.y, c.radius]);
}
let mut best: Option<(f64, Circle)> = None;
let scale = rad.max(p1.dist(p2)).max(1e-6);
for e1 in eq_variants(&o1) {
for e2 in eq_variants(&o2) {
for e3 in eq_variants(&o3) {
for st in &starts {
let mut x = *st;
let mut converged = false;
for _ in 0..60 {
let (f1, g1) = e1.eval(x);
let (f2, g2) = e2.eval(x);
let (f3, g3) = e3.eval(x);
if f1.abs().max(f2.abs()).max(f3.abs()) < 1e-11 * scale {
converged = true;
break;
}
let Some(dx) = solve3([g1, g2, g3], [-f1, -f2, -f3]) else { break };
for (xi, di) in x.iter_mut().zip(dx) {
*xi += di;
}
if !x.iter().all(|v| v.is_finite()) {
break;
}
}
if !converged || x[2] <= 1e-9 * scale || x[2] > 1e6 * scale {
continue;
}
let c = Circle::new(Vec2::new(x[0], x[1]), x[2]);
let sc = score(&c, &objs);
if best.is_none_or(|(b, _)| sc < b - 1e-12) {
best = Some((sc, c));
}
}
}
}
}
best.map(|(_, c)| c)
}
// ---------------- curves as segment chains ----------------
/// A curve flattened to lines/arcs (exact for lines, arcs, circles and polylines; tessellated for
/// ellipses, splines and 3D polylines).
#[derive(Clone, Debug, Default)]
pub struct Chain {
pub segs: Vec<Segment>,
pub closed: bool,
}
impl Chain {
pub fn of(k: &EntityKind) -> Option<Chain> {
let tess = |pts: Vec<Vec2>, closed: bool| Chain {
segs: pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).filter(|s| s.len() > 1e-15).collect(),
closed,
};
let c = match k {
EntityKind::Line(l) => Chain { segs: vec![Segment::Line(Line::new(l.a.xy(), l.b.xy()))], closed: false },
EntityKind::Arc(a) => {
Chain { segs: vec![Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }], closed: false }
}
EntityKind::Circle(c) => {
let o = c.center.xy();
Chain {
segs: vec![
Segment::Arc { arc: Arc::new(o, c.radius, 0.0, std::f64::consts::PI), ccw: true },
Segment::Arc { arc: Arc::new(o, c.radius, std::f64::consts::PI, TAU), ccw: true },
],
closed: true,
}
}
EntityKind::LwPolyline(p) => Chain { segs: Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments(), closed: p.closed },
EntityKind::Polyline3d(p) => {
let mut pts: Vec<Vec2> = p.points.iter().map(|q| q.xy()).collect();
if p.closed
&& let Some(f) = pts.first().copied()
{
pts.push(f);
}
tess(pts, p.closed)
}
EntityKind::Ellipse(e) => {
let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
let mut pts = Vec::new();
ge.tessellate(e.major.xy().len() * 1e-4, &mut pts);
tess(pts, ge.is_full())
}
EntityKind::Spline(sp) => tess(sp.tessellate(1e-4), sp.closed),
_ => return None,
};
(!c.segs.is_empty()).then_some(c)
}
pub fn len(&self) -> f64 {
self.segs.iter().map(Segment::len).sum()
}
pub fn start(&self) -> Option<Vec2> {
self.segs.first().map(Segment::start)
}
pub fn end(&self) -> Option<Vec2> {
self.segs.last().map(Segment::end)
}
/// Point and unit tangent at arc length `d` from the start (clamped).
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
let mut acc = 0.0;
for s in &self.segs {
let l = s.len();
if d <= acc + l + 1e-12 && l > 0.0 {
let t = ((d - acc) / l).clamp(0.0, 1.0);
return Some((s.at(t), s.tangent(t)));
}
acc += l;
}
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
}
/// Arc length of the point on the chain nearest `p`.
pub fn length_of(&self, p: Vec2) -> f64 {
let mut acc = 0.0;
let mut best = (f64::INFINITY, 0.0);
for s in &self.segs {
let c = s.closest(p);
let dd = c.dist(p);
if dd < best.0 {
best = (dd, acc + seg_param(s, c) * s.len());
}
acc += s.len();
}
best.1
}
/// Reversed copy.
pub fn reversed(&self) -> Chain {
Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed }
}
/// Polyline vertices for the chain.
pub fn vertices(&self) -> Vec<PolyVertex> {
let mut vs: Vec<PolyVertex> = self
.segs
.iter()
.map(|s| PolyVertex {
p: s.start(),
bulge: match s {
Segment::Line(_) => 0.0,
Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }),
},
..Default::default()
})
.collect();
if !self.closed
&& let Some(e) = self.end()
{
vs.push(PolyVertex::new(e));
}
vs
}
}
/// Parameter (0..1 along travel) of a point on a segment.
pub fn seg_param(s: &Segment, p: Vec2) -> f64 {
match s {
Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0),
Segment::Arc { arc, ccw } => {
let t = (cadcraft_geom::norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15)).clamp(0.0, 1.0);
if *ccw { t } else { 1.0 - t }
}
}
}
/// Distance from `p` to an entity's geometry (prims; text and blocks by insertion point).
pub fn kind_distance(k: &EntityKind, p: Vec2) -> f64 {
k.prims()
.iter()
.map(|pr| match pr {
Prim::Seg(s) => s.closest(p).dist(p),
Prim::Circle(c) => c.closest(p).dist(p),
Prim::Ellipse(e) => e.closest(p).dist(p),
Prim::Spline(sp) => {
let pts = sp.tessellate(1e-3);
pts.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).closest(p).dist(p))).fold(f64::INFINITY, f64::min)
}
Prim::Point(q) => q.dist(p),
Prim::Infinite { base, dir, .. } => Line::new(*base, *base + *dir).project(p).dist(p),
Prim::Fill(f) => {
let n = f.len();
(0..n).filter_map(|i| Some(Line::new(*f.get(i)?, *f.get((i + 1) % n)?).closest(p).dist(p))).fold(f64::INFINITY, f64::min)
}
})
.fold(f64::INFINITY, f64::min)
}
// ---------------- splines ----------------
/// Insert knot `t` once (Boehm), in homogeneous coordinates.
fn insert_knot(sp: &Spline, t: f64) -> Option<Spline> {
if !sp.is_valid() {
return None;
}
let p = sp.degree;
let n = sp.control.len();
let w = |i: usize| if sp.weights.is_empty() { 1.0 } else { sp.weights.get(i).copied().unwrap_or(1.0) };
// Span k with knots[k] <= t < knots[k+1].
let (lo, hi) = sp.domain();
let t = t.clamp(lo, hi);
let mut k = p;
for i in p..n {
if sp.knots.get(i).copied()? <= t {
k = i;
}
}
let mut ctrl: Vec<(Vec2, f64)> = Vec::with_capacity(n + 1);
for i in 0..=n {
let pt = if i + p <= k {
let c = sp.control.get(i).copied()?;
(c * w(i), w(i))
} else if i > k {
let c = sp.control.get(i - 1).copied()?;
(c * w(i - 1), w(i - 1))
} else {
let ki = sp.knots.get(i).copied()?;
let kip = sp.knots.get(i + p).copied()?;
let a = if (kip - ki).abs() < 1e-300 { 0.0 } else { (t - ki) / (kip - ki) };
let c0 = sp.control.get(i - 1).copied()?;
let c1 = sp.control.get(i).copied()?;
let (h0, w0) = (c0 * w(i - 1), w(i - 1));
let (h1, w1) = (c1 * w(i), w(i));
(h0 * (1.0 - a) + h1 * a, w0 * (1.0 - a) + w1 * a)
};
ctrl.push(pt);
}
let mut knots = sp.knots.clone();
knots.insert(k + 1, t);
let rational = !sp.weights.is_empty();
Some(Spline {
degree: p,
knots,
control: ctrl.iter().map(|(h, w)| if w.abs() > 1e-300 { *h / *w } else { *h }).collect(),
weights: if rational { ctrl.iter().map(|(_, w)| *w).collect() } else { Vec::new() },
fit: Vec::new(),
closed: false,
})
}
/// Split a spline at parameter `t` into (left, right). `None` at the ends.
pub fn split_spline(sp: &Spline, t: f64) -> Option<(Spline, Spline)> {
let (lo, hi) = sp.domain();
if !(t > lo + 1e-12 && t < hi - 1e-12) {
return None;
}
let mut s = sp.clone();
s.fit.clear();
let mult = |s: &Spline| s.knots.iter().filter(|k| (**k - t).abs() < 1e-12).count();
let mut guard = 0;
while mult(&s) < s.degree && guard < 64 {
s = insert_knot(&s, t)?;
guard += 1;
}
let p = s.degree;
// Knots equal to t now have multiplicity p; first index of t:
let first = s.knots.iter().position(|k| (*k - t).abs() < 1e-12)?;
// Left: control points 0..first (count = first), knots 0..first+p ... + t
let left_n = first;
let mut lk: Vec<f64> = s.knots.get(..first)?.to_vec();
lk.extend(std::iter::repeat_n(t, p + 1));
let lc = s.control.get(..left_n)?.to_vec();
let lw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(..left_n)?.to_vec() };
let right_start = first.checked_sub(1)?;
let mut rk = vec![t; p + 1];
rk.extend(s.knots.get(first + p..)?.iter().copied());
let rc = s.control.get(right_start..)?.to_vec();
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(right_start..)?.to_vec() };
let l = Spline { degree: p, knots: lk, control: lc, weights: lw, fit: Vec::new(), closed: false };
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, fit: Vec::new(), closed: false };
(l.is_valid() && r.is_valid()).then_some((l, r))
}
/// The part of a spline between parameters `a < b`.
pub fn sub_spline(sp: &Spline, a: f64, b: f64) -> Option<Spline> {
let (lo, hi) = sp.domain();
let mut s = sp.clone();
s.fit.clear();
s.closed = false;
if b < hi - 1e-12 {
s = split_spline(&s, b)?.0;
}
if a > lo + 1e-12 {
s = split_spline(&s, a)?.1;
}
Some(s)
}
/// Parameter of the point on the spline nearest `p`.
pub fn spline_param_of(sp: &Spline, p: Vec2) -> f64 {
let (lo, hi) = sp.domain();
let n = 400;
let mut best = (f64::INFINITY, lo);
for i in 0..=n {
let t = lo + (hi - lo) * i as f64 / n as f64;
let d = sp.eval(t).dist(p);
if d < best.0 {
best = (d, t);
}
}
let step = (hi - lo) / n as f64;
let (mut a, mut b) = ((best.1 - step).max(lo), (best.1 + step).min(hi));
for _ in 0..60 {
let m1 = a + (b - a) / 3.0;
let m2 = b - (b - a) / 3.0;
if sp.eval(m1).dist(p) < sp.eval(m2).dist(p) {
b = m2;
} else {
a = m1;
}
}
(a + b) / 2.0
}
/// Reverse a spline's direction (control, weights, fit points and knots).
pub fn reverse_spline(sp: &Spline) -> Spline {
let (lo, hi) = (sp.knots.first().copied().unwrap_or(0.0), sp.knots.last().copied().unwrap_or(1.0));
Spline {
degree: sp.degree,
knots: sp.knots.iter().rev().map(|k| lo + hi - k).collect(),
control: sp.control.iter().rev().copied().collect(),
weights: sp.weights.iter().rev().copied().collect(),
fit: sp.fit.iter().rev().copied().collect(),
closed: sp.closed,
}
}
/// Reverse an LWPOLYLINE's vertex order, keeping its shape (bulges and widths move with segments).
pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
let n = vs.len();
if n < 2 {
return vs.to_vec();
}
(0..n)
.map(|k| {
let p = vs.get(n - 1 - k).map(|v| v.p).unwrap_or_default();
let seg = (2 * n - 2 - k) % n; // old segment index reversed into new segment k
let has_seg = closed || k + 1 < n;
match (has_seg, vs.get(seg)) {
(true, Some(o)) => PolyVertex { p, bulge: -o.bulge, start_width: o.end_width, end_width: o.start_width },
_ => PolyVertex::new(p),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arc_builders() {
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
assert!(a.end_point().near(Vec2::new(0.0, 1.0), 1e-9));
let b = arc_sea(Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), std::f64::consts::PI).unwrap();
assert!(b.center.near(Vec2::ZERO, 1e-9));
assert!(b.mid_point().near(Vec2::new(0.0, 1.0), 1e-9));
let c = arc_sed(Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::Y).unwrap();
assert!(c.mid_point().near(Vec2::new(0.0, 1.0), 1e-9));
let d = arc_ser(Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), 1.0).unwrap();
assert!(d.center.near(Vec2::ZERO, 1e-9));
let e = arc_scl(Vec2::new(1.0, 0.0), Vec2::ZERO, 2f64.sqrt()).unwrap();
assert!(e.end_point().near(Vec2::new(0.0, 1.0), 1e-9));
assert!(arc_scl(Vec2::new(1.0, 0.0), Vec2::ZERO, 5.0).is_none());
assert!((bulge_through(Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)) - 1.0).abs() < 1e-9);
assert!((bulge_through(Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0)) + 1.0).abs() < 1e-9);
}
#[test]
fn tangent_circles() {
let l1 = TanObj::Line(Line::new(Vec2::ZERO, Vec2::new(10.0, 0.0)));
let l2 = TanObj::Line(Line::new(Vec2::ZERO, Vec2::new(0.0, 10.0)));
let c = ttr(l1, Vec2::new(3.0, 0.0), l2, Vec2::new(0.0, 3.0), 1.0).unwrap();
assert!(c.center.near(Vec2::new(1.0, 1.0), 1e-9));
let l3 = TanObj::Line(Line::new(Vec2::new(4.0, 0.0), Vec2::new(0.0, 3.0)));
let inc = ttt([(l1, Vec2::new(2.0, 0.0)), (l2, Vec2::new(0.0, 1.0)), (l3, Vec2::new(2.0, 1.5))]).unwrap();
assert!(inc.center.near(Vec2::new(1.0, 1.0), 1e-6), "{inc:?}");
assert!((inc.radius - 1.0).abs() < 1e-6);
let k = TanObj::Circle(Circle::new(Vec2::new(5.0, 0.0), 2.0));
let c2 = ttr(l2, Vec2::new(0.0, 0.0), k, Vec2::new(3.0, 0.0), 1.5).unwrap();
assert!((c2.center.x - 1.5).abs() < 1e-9);
}
#[test]
fn spline_split_and_reverse() {
let sp = Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 2.0), Vec2::new(4.0, 0.0)], 3);
let (lo, hi) = sp.domain();
let t = lo + (hi - lo) * 0.3;
let (l, r) = split_spline(&sp, t).unwrap();
let p = sp.eval(t);
assert!(l.eval(l.domain().1).near(p, 1e-9));
assert!(r.eval(r.domain().0).near(p, 1e-9));
assert!(r.eval(r.domain().1).near(Vec2::new(4.0, 0.0), 1e-9));
let mid = sp.eval(lo + (hi - lo) * 0.6);
assert!(r.eval(r.domain().0 + (r.domain().1 - r.domain().0) * (0.3 / 0.7)).near(mid, 1e-9));
let rv = reverse_spline(&sp);
assert!(rv.eval(rv.domain().0).near(Vec2::new(4.0, 0.0), 1e-9));
assert!((spline_param_of(&sp, p) - t).abs() < 1e-6);
let sub = sub_spline(&sp, lo + 0.2 * (hi - lo), lo + 0.8 * (hi - lo)).unwrap();
assert!(sub.eval(sub.domain().0).near(sp.eval(lo + 0.2 * (hi - lo)), 1e-9));
}
#[test]
fn reverse_polyline_vertices() {
let vs = vec![PolyVertex::with_bulge(Vec2::ZERO, 0.5), PolyVertex::new(Vec2::new(1.0, 0.0)), PolyVertex::new(Vec2::new(1.0, 1.0))];
let r = reverse_vertices(&vs, false);
assert!(r[0].p.near(Vec2::new(1.0, 1.0), 0.0));
assert_eq!(r[0].bulge, 0.0);
assert!((r[1].bulge + 0.5).abs() < 1e-12);
assert_eq!(r[2].bulge, 0.0);
let r2 = reverse_vertices(&r, false);
assert_eq!(r2, vs);
}
}
+30 -193
View File
@@ -31,12 +31,12 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Draw", "Arc", "3 Points"])
.alias(&["a"])
.params("{p1, p2, p3} | {center, radius, start, end (degrees)} | {start, center, end}")
.interactive(|_| Ok(Box::new(ArcM::default()))),
CommandSpec::new("rectang", "Rectangle", run_rectang)
.interactive(|_| Ok(super::draw2::arc_machine())),
CommandSpec::new("rectang", "Rectangle", super::draw2::run_rectang2)
.menu(&["Draw", "Rectangle"])
.alias(&["rec", "rectangle"])
.params("{p1, p2, fillet?, chamfer?: [d1, d2], width?}")
.interactive(|_| Ok(Box::new(RectM::default()))),
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?}")
.interactive(|_| Ok(Box::new(super::draw2::RectM2::default()))),
CommandSpec::new("polygon", "Polygon", run_polygon)
.menu(&["Draw", "Polygon"])
.alias(&["pol"])
@@ -56,11 +56,11 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Draw", "Point", "Multiple Point"])
.params("{points: [...]}")
.interactive(|_| Ok(Box::new(PointM { multiple: true }))),
CommandSpec::new("xline", "Construction Line", run_xline)
CommandSpec::new("xline", "Construction Line", super::draw2::run_xline2)
.menu(&["Draw", "Construction Line"])
.alias(&["xl"])
.params("{base, through} | {base, angle (degrees)}")
.interactive(|_| Ok(Box::new(XlineM { ray: false, base: None, fixed_angle: None }))),
.params("{base, through} | {base, angle (degrees)} | {base, hor|ver: true} | {vertex, start, end} (bisect) | {handle, distance, side} | {handle, through} (offset)")
.interactive(|_| Ok(Box::new(super::draw2::XlineM2::default()))),
CommandSpec::new("ray", "Ray", run_ray)
.menu(&["Draw", "Ray"])
.params("{base, through}")
@@ -175,22 +175,6 @@ fn run_arc(s: &mut Session, p: &Value) -> Result<Value> {
added(s.add_entity(arc(&a))?)
}
fn run_rectang(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("rectang", p, "p1")?;
let b = point_req("rectang", p, "p2")?;
let ch = p
.get("chamfer")
.and_then(Value::as_array)
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
.unwrap_or((0.0, 0.0));
let vs = rect_with_corners(a, b, f64_or(p, "fillet", 0.0), ch);
let mut k = lwpoly(vs, true);
if let EntityKind::LwPolyline(pl) = &mut k {
pl.const_width = f64_or(p, "width", 0.0).max(0.0);
}
added(s.add_entity(k)?)
}
fn run_polygon(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("sides").and_then(Value::as_u64).unwrap_or(4) as usize;
if !(3..=1024).contains(&n) {
@@ -238,18 +222,6 @@ fn run_point(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": hs }))
}
fn run_xline(s: &mut Session, p: &Value) -> Result<Value> {
let b = point_req("xline", p, "base")?;
let dir = match point_param(p, "through") {
Some(t) => (t - b).normalized(),
None => Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians()),
};
if dir == Vec2::ZERO {
return Err(bad("xline", "through point equals base"));
}
added(s.add_entity(EntityKind::XLine(RayLine { base: v3(b), dir: v3(dir) }))?)
}
fn run_ray(s: &mut Session, p: &Value) -> Result<Value> {
let b = point_req("ray", p, "base")?;
let t = point_req("ray", p, "through")?;
@@ -581,6 +553,8 @@ struct CircleM {
mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR
pts: Vec<Vec2>,
diameter: bool,
/// The `Ttr` option hands over to the tangent-circle machine.
delegate: Option<Box<dyn Interactive>>,
}
impl Interactive for CircleM {
@@ -588,6 +562,9 @@ impl Interactive for CircleM {
"CIRCLE"
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some(d) = &self.delegate {
return d.prompt(s);
}
let n = self.pts.len();
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
match (self.mode, n) {
@@ -611,6 +588,9 @@ impl Interactive for CircleM {
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(d) = &mut self.delegate {
return d.input(s, i);
}
match i {
Input::Keyword(k) => {
self.mode = match k.as_str() {
@@ -621,8 +601,8 @@ impl Interactive for CircleM {
self.mode
}
_ => {
s.echo("Ttr: not available yet");
self.mode
self.delegate = Some(Box::new(super::draw2::TanCircleM::new(true)));
3
}
};
Ok(Step::Continue)
@@ -667,7 +647,10 @@ impl Interactive for CircleM {
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some(d) = &self.delegate {
return d.preview(s, c);
}
match (self.mode, self.pts.as_slice()) {
(0, [center]) => {
let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) };
@@ -689,161 +672,6 @@ fn finish_circle(s: &mut Session, c: Circle) -> Result<()> {
Ok(())
}
#[derive(Default)]
struct ArcM {
pts: Vec<Vec2>,
center_first: bool,
center: Option<Vec2>,
}
impl Interactive for ArcM {
fn name(&self) -> &'static str {
"ARC"
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.center_first {
return match (self.center, self.pts.len()) {
(None, _) => Prompt::new("Specify center point of arc", Accept::POINT),
(Some(c), 0) => Prompt::new("Specify start point of arc", Accept::POINT).base(c),
(Some(c), _) => {
Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).kw(&["Angle", "chord Length"]).base(c)
}
};
}
match self.pts.len() {
0 => Prompt::new("Specify start point of arc", Accept::POINT).kw(&["Center"]),
1 => Prompt::new("Specify second point of arc", Accept::POINT).kw(&["Center", "End"]).base_opt(self.pts.last().copied()),
_ => Prompt::new("Specify end point of arc", Accept::POINT).base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Keyword(k) if k == "Center" => {
self.center_first = true;
if !self.pts.is_empty() {
// Start point already given: S,C,E.
self.center = None;
}
Ok(Step::Continue)
}
Input::Point(p) => {
if self.center_first {
if self.center.is_none() && self.pts.is_empty() {
self.center = Some(p);
return Ok(Step::Continue);
}
if self.center.is_none() {
self.center = Some(p);
return Ok(Step::Continue);
}
self.pts.push(p);
if let (Some(c), 2) = (self.center, self.pts.len()) {
s.add_entity(arc(&Arc::from_start_center_end(self.pts[0], c, self.pts[1])))?;
return Ok(Step::Done);
}
return Ok(Step::Continue);
}
self.pts.push(p);
if self.pts.len() == 3 {
match Arc::from_3_points(self.pts[0], self.pts[1], self.pts[2]) {
Some(a) => {
s.add_entity(arc(&a))?;
return Ok(Step::Done);
}
None => {
self.pts.pop();
return Err(crate::EngineError::Other("Points are collinear.".into()));
}
}
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.center_first {
return match (self.center, self.pts.as_slice()) {
(Some(ctr), [st]) => vec![arc(&Arc::from_start_center_end(*st, ctr, c))],
(Some(ctr), []) => vec![line(ctr, c)],
_ => Vec::new(),
};
}
match self.pts.as_slice() {
[a, b] => Arc::from_3_points(*a, *b, c).map(|ar| vec![arc(&ar)]).unwrap_or_else(|| vec![line(*a, c)]),
[a] => vec![line(*a, c)],
_ => Vec::new(),
}
}
}
#[derive(Default)]
struct RectM {
first: Option<Vec2>,
fillet: f64,
chamfer: (f64, f64),
asking: Option<&'static str>,
}
impl Interactive for RectM {
fn name(&self) -> &'static str {
"RECTANG"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(a) = self.asking {
return Prompt::new(a, Accept::NUMBER);
}
match self.first {
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(a) = self.asking {
if let Input::Text(t) = &i {
let v = number(t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
match a {
"Specify fillet radius for rectangles" => self.fillet = v.max(0.0),
"Specify first chamfer distance for rectangles" => {
self.chamfer.0 = v.max(0.0);
self.asking = Some("Specify second chamfer distance for rectangles");
return Ok(Step::Continue);
}
_ => self.chamfer.1 = v.max(0.0),
}
}
self.asking = None;
return Ok(Step::Continue);
}
match i {
Input::Keyword(k) => {
match k.as_str() {
"Fillet" => self.asking = Some("Specify fillet radius for rectangles"),
"Chamfer" => self.asking = Some("Specify first chamfer distance for rectangles"),
_ => s.echo(format!("{k}: not available yet")),
}
Ok(Step::Continue)
}
Input::Point(p) => match self.first {
None => {
self.first = Some(p);
Ok(Step::Continue)
}
Some(a) => {
s.add_entity(lwpoly(rect_with_corners(a, p, self.fillet, self.chamfer), true))?;
Ok(Step::Done)
}
},
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
self.first.map(|a| vec![lwpoly(rect_with_corners(a, c, self.fillet, self.chamfer), true)]).unwrap_or_default()
}
}
#[derive(Default)]
struct PolygonM {
sides: Option<usize>,
@@ -932,6 +760,11 @@ impl Interactive for PolygonM {
}
}
/// ELLIPSE prompt machine starting in axis-end or center mode, optionally for an elliptical arc.
pub(crate) fn ellipse_machine(center: bool, arc: bool) -> Box<dyn Interactive> {
Box::new(EllipseM { center_mode: center, arc_mode: arc, ..Default::default() })
}
#[derive(Default)]
struct EllipseM {
center_mode: bool,
@@ -1036,6 +869,10 @@ impl Interactive for EllipseM {
if let Some((c, m)) = self.axis()
&& let Some(e) = self.build(c + m.normalized().perp() * d)
{
if self.arc_mode {
self.ellipse = Some(e);
return Ok(Step::Continue);
}
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
center: v3(e.center),
major: v3(e.major),
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
use cadcraft_doc::EntityKind;
use cadcraft_geom::{Arc, Vec2};
use serde_json::json;
use crate::{Input, Session};
fn last(s: &Session) -> EntityKind {
s.doc().unwrap().model.last().unwrap().kind.clone()
}
fn kinds(s: &Session) -> Vec<&'static str> {
s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect()
}
fn as_arc(k: &EntityKind) -> Arc {
match k {
EntityKind::Arc(a) => Arc::new(a.center.xy(), a.radius, a.start, a.end),
other => panic!("not an arc: {other:?}"),
}
}
fn as_circle(k: &EntityKind) -> (Vec2, f64) {
match k {
EntityKind::Circle(c) => (c.center.xy(), c.radius),
other => panic!("not a circle: {other:?}"),
}
}
fn h(r: &serde_json::Value) -> String {
r["handle"].as_str().unwrap().to_string()
}
fn near(a: Vec2, x: f64, y: f64) -> bool {
a.near(Vec2::new(x, y), 1e-6)
}
// ---------------- ARC ----------------
#[test]
fn arc_variants_json() {
let mut s = Session::new();
let quarter =
|a: &Arc| near(a.center, 0.0, 0.0) && (a.radius - 1.0).abs() < 1e-9 && near(a.start_point(), 1.0, 0.0) && near(a.end_point(), 0.0, 1.0);
for (id, p) in [
("arc.sce", json!({"start": [1, 0], "center": [0, 0], "end": [0, 5]})),
("arc.sca", json!({"start": [1, 0], "center": [0, 0], "angle": 90})),
("arc.scl", json!({"start": [1, 0], "center": [0, 0], "length": 2f64.sqrt()})),
("arc.sea", json!({"start": [1, 0], "end": [0, 1], "angle": 90})),
("arc.sed", json!({"start": [1, 0], "end": [0, 1], "direction": 90})),
("arc.ser", json!({"start": [1, 0], "end": [0, 1], "radius": 1})),
("arc.cse", json!({"center": [0, 0], "start": [1, 0], "end": [0, 3]})),
("arc.csa", json!({"center": [0, 0], "start": [1, 0], "angle": 90})),
("arc.csl", json!({"center": [0, 0], "start": [1, 0], "length": 2f64.sqrt()})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
assert!(quarter(&as_arc(&last(&s))), "{id}: {:?}", last(&s));
}
// Negative angle: clockwise from the start.
s.execute("arc.sca", &json!({"start": [1, 0], "center": [0, 0], "angle": -90})).unwrap();
let a = as_arc(&last(&s));
assert!(near(a.start_point(), 0.0, -1.0) && near(a.end_point(), 1.0, 0.0));
// Negative radius / chord: the major arc.
s.execute("arc.ser", &json!({"start": [1, 0], "end": [0, 1], "radius": -1})).unwrap();
assert!((as_arc(&last(&s)).sweep() - 1.5 * std::f64::consts::PI).abs() < 1e-9);
s.execute("arc.scl", &json!({"start": [1, 0], "center": [0, 0], "length": -(2f64.sqrt())})).unwrap();
assert!((as_arc(&last(&s)).sweep() - 1.5 * std::f64::consts::PI).abs() < 1e-9);
// Direction as a vector, and bad input.
s.execute("arc.sed", &json!({"start": [1, 0], "end": [0, 1], "direction": [0, 1]})).unwrap();
assert!(quarter(&as_arc(&last(&s))));
assert!(s.execute("arc.ser", &json!({"start": [0, 0], "end": [10, 0], "radius": 1})).is_err());
assert!(s.execute("arc.scl", &json!({"start": [1, 0], "center": [0, 0], "length": 5})).is_err());
assert!(s.execute("arc.sea", &json!({"start": [1, 0], "end": [1, 0], "angle": 90})).is_err());
}
#[test]
fn arc_variants_command_line() {
for (cmd, expect_quarter) in [
("arc.sce 1,0 0,0 0,2", true),
("arc.sca 1,0 0,0 90", true),
("arc.scl 1,0 0,0 1.41421356237", true),
("arc.sea 1,0 0,1 90", true),
("arc.sed 1,0 0,1 90", true),
("arc.ser 1,0 0,1 1", true),
("arc.cse 0,0 1,0 0,4", true),
("arc.csa 0,0 1,0 90", true),
("arc.csl 0,0 1,0 1.41421356237", true),
// Angle/direction given by a point.
("arc.sed 1,0 0,1 1,5", true),
("arc.csa 0,0 1,0 0,7", true),
] {
let mut s = Session::new();
s.cmdline(cmd).unwrap();
assert!(s.running.is_none(), "{cmd} still running: {}", s.prompt_text());
let a = as_arc(&last(&s));
if expect_quarter {
assert!(near(a.center, 0.0, 0.0) && near(a.start_point(), 1.0, 0.0) && near(a.end_point(), 0.0, 1.0), "{cmd}: {a:?}");
}
assert_eq!(s.state().unwrap().undo.len(), 1, "{cmd}: one undo step");
}
}
#[test]
fn arc_continue_from_last_line() {
let mut s = Session::new();
// Nothing to continue from: the command ends politely.
s.cmdline("arc.continue").unwrap();
assert!(s.running.is_none());
assert!(s.execute("arc.continue", &json!({"end": [2, 1]})).is_err());
s.cmdline("line 0,0 1,0").unwrap();
s.cmdline("").unwrap();
s.cmdline("arc.continue 2,1").unwrap();
let a = as_arc(&last(&s));
assert!(near(a.center, 1.0, 1.0), "{a:?}");
// JSON continues from that arc's end (2,1) heading +Y.
s.execute("arc.continue", &json!({"end": [1, 2]})).unwrap();
let b = as_arc(&last(&s));
assert!(near(b.center, 1.0, 1.0), "{b:?}");
assert!(s.execute("arc.continue", &json!({"end": [-5, 2]})).is_err(), "end on the tangent line");
// Explicit start and direction.
s.execute("arc.continue", &json!({"start": [0, 0], "direction": 0, "end": [1, 1]})).unwrap();
assert!(near(as_arc(&last(&s)).center, 0.0, 1.0));
}
// ---------------- CIRCLE ----------------
#[test]
fn circle_variants_json_and_command_line() {
let mut s = Session::new();
s.execute("circle.cd", &json!({"center": [1, 1], "diameter": 4})).unwrap();
assert_eq!(as_circle(&last(&s)).1, 2.0);
s.execute("circle.2p", &json!({"p1": [0, 0], "p2": [6, 0]})).unwrap();
let (c, r) = as_circle(&last(&s));
assert!(near(c, 3.0, 0.0) && (r - 3.0).abs() < 1e-9);
s.execute("circle.3p", &json!({"p1": [1, 0], "p2": [0, 1], "p3": [-1, 0]})).unwrap();
let (c, r) = as_circle(&last(&s));
assert!(near(c, 0.0, 0.0) && (r - 1.0).abs() < 1e-9);
assert!(s.execute("circle.3p", &json!({"p1": [0, 0], "p2": [1, 0], "p3": [2, 0]})).is_err());
assert!(s.execute("circle.cd", &json!({"center": [0, 0], "diameter": 0})).is_err());
s.cmdline("circle.cd 5,5 3").unwrap();
assert_eq!(as_circle(&last(&s)).1, 1.5);
s.cmdline("circle.cd 5,5 5,9").unwrap();
assert_eq!(as_circle(&last(&s)).1, 2.0);
s.cmdline("circle.2p 0,0 0,4").unwrap();
assert!(near(as_circle(&last(&s)).0, 0.0, 2.0));
s.cmdline("circle.3p 0,0 2,2 4,0").unwrap();
assert!(near(as_circle(&last(&s)).0, 2.0, 0.0));
assert!(s.running.is_none());
}
fn line_h(s: &mut Session, pts: serde_json::Value) -> String {
s.execute("line", &json!({ "points": pts })).unwrap()["handles"][0].as_str().unwrap().to_string()
}
fn corner(s: &mut Session) -> (String, String) {
(line_h(s, json!([[0, 0], [10, 0]])), line_h(s, json!([[0, 0], [0, 10]])))
}
#[test]
fn circle_ttr_json_and_command_line() {
let mut s = Session::new();
let (a, b) = corner(&mut s);
s.execute("circle.ttr", &json!({"h1": a, "p1": [3, 0], "h2": b, "p2": [0, 3], "radius": 1})).unwrap();
let (c, r) = as_circle(&last(&s));
assert!(near(c, 1.0, 1.0) && (r - 1.0).abs() < 1e-9);
// Circle-circle and line-circle.
let k = h(&s.execute("circle", &json!({"center": [20, 0], "radius": 2})).unwrap());
let k2 = h(&s.execute("circle", &json!({"center": [26, 0], "radius": 2})).unwrap());
s.execute("circle.ttr", &json!({"h1": k, "p1": [21, 1], "h2": k2, "p2": [25, 1], "radius": 2})).unwrap();
let (c, r) = as_circle(&last(&s));
assert!((c.x - 23.0).abs() < 1e-9 && c.y > 0.0 && (r - 2.0).abs() < 1e-9, "{c:?}");
assert!(s.execute("circle.ttr", &json!({"h1": k, "p1": [21, 1], "h2": k2, "p2": [25, 1], "radius": 0.1})).is_err());
// Command line: picks, then the radius.
s.cmdline("circle.ttr 5,0 0,5 2").unwrap();
let (c, r) = as_circle(&last(&s));
assert!(near(c, 2.0, 2.0) && (r - 2.0).abs() < 1e-9, "{c:?} {r}");
// CIRCLE's Ttr option.
s.cmdline("circle ttr 6,0 0,6 0.5").unwrap();
assert!(s.running.is_none());
let (c, r) = as_circle(&last(&s));
assert!(near(c, 0.5, 0.5) && (r - 0.5).abs() < 1e-9);
}
#[test]
fn circle_ttt_json_and_command_line() {
let mut s = Session::new();
let (a, b) = corner(&mut s);
let hyp = s.execute("line", &json!({"points": [[4, 0], [0, 3]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
s.execute("circle.ttt", &json!({"h1": a, "p1": [2, 0], "h2": b, "p2": [0, 1], "h3": hyp, "p3": [2, 1.5]})).unwrap();
let (c, r) = as_circle(&last(&s));
assert!(near(c, 1.0, 1.0) && (r - 1.0).abs() < 1e-6, "{c:?} {r}");
s.cmdline("circle.ttt 2,0 0,1 2,1.5").unwrap();
assert!(s.running.is_none());
let (c, _) = as_circle(&last(&s));
assert!(near(c, 1.0, 1.0));
// Three circles: the small circle in the middle of three touching circles.
let mut s = Session::new();
let r = 1.0;
let cs: Vec<String> =
[[0.0, 0.0], [2.0, 0.0], [1.0, 3f64.sqrt()]].iter().map(|c| h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap())).collect();
let centroid = Vec2::new(1.0, 3f64.sqrt() / 3.0);
let picks: Vec<Vec2> = [[0.0, 0.0], [2.0, 0.0], [1.0, 3f64.sqrt()]]
.iter()
.map(|c| Vec2::new(c[0], c[1]) + (centroid - Vec2::new(c[0], c[1])).normalized())
.collect();
s.execute(
"circle.ttt",
&json!({"h1": cs[0], "p1": [picks[0].x, picks[0].y], "h2": cs[1], "p2": [picks[1].x, picks[1].y], "h3": cs[2], "p3": [picks[2].x, picks[2].y]}),
)
.unwrap();
let (c, rr) = as_circle(&last(&s));
assert!(c.near(centroid, 1e-6) && (rr - (2.0 / 3f64.sqrt() - 1.0)).abs() < 1e-6, "{c:?} {rr}");
}
// ---------------- ELLIPSE ----------------
#[test]
fn ellipse_axis_and_arc() {
let mut s = Session::new();
s.execute("ellipse.axis", &json!({"p1": [0, 0], "p2": [4, 0], "distance": 1})).unwrap();
match last(&s) {
EntityKind::Ellipse(e) => {
assert!(near(e.center.xy(), 2.0, 0.0) && (e.ratio - 0.5).abs() < 1e-9);
}
k => panic!("{k:?}"),
}
s.execute("ellipse.arc", &json!({"center": [0, 0], "major": [2, 0], "ratio": 0.5, "start": 0, "end": 90})).unwrap();
match last(&s) {
EntityKind::Ellipse(e) => assert!((e.end - std::f64::consts::FRAC_PI_2).abs() < 1e-9 && e.start.abs() < 1e-9),
k => panic!("{k:?}"),
}
assert!(s.execute("ellipse.arc", &json!({"p1": [0, 0], "p2": [4, 0], "distance": 1})).is_err());
s.cmdline("ellipse.axis 0,0 6,0 1").unwrap();
assert!(matches!(last(&s), EntityKind::Ellipse(e) if (e.ratio - 1.0 / 3.0).abs() < 1e-9));
s.cmdline("ellipse.arc 0,0 4,0 1 0 90").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(matches!(last(&s), EntityKind::Ellipse(e) if (e.end - e.start).abs() > 0.1 && (e.end - e.start).abs() < 6.0));
}
// ---------------- DIVIDE / MEASURE ----------------
#[test]
fn divide_and_measure_json() {
let mut s = Session::new();
let l = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
let r = s.execute("divide", &json!({"handle": l, "segments": 5})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 4);
let pts: Vec<Vec2> =
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
assert!(near(pts[0], 2.0, 0.0) && near(pts[3], 8.0, 0.0));
let r = s.execute("measure", &json!({"handle": l, "length": 3})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 3);
// From the other end.
s.execute("measure", &json!({"handle": l, "length": 4, "from": [10, 0]})).unwrap();
assert!(matches!(last(&s), EntityKind::Point(p) if near(p.p.xy(), 2.0, 0.0)));
// A circle divides into n points including the start.
let c = h(&s.execute("circle", &json!({"center": [20, 0], "radius": 1})).unwrap());
assert_eq!(s.execute("divide", &json!({"handle": c, "segments": 6})).unwrap()["handles"].as_array().unwrap().len(), 6);
// Blocks, aligned with the object.
let tick = s.execute("line", &json!({"points": [[0, -0.1], [0, 0.1]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
s.execute("block", &json!({"name": "TICK", "base": [0, 0], "handles": [tick], "keep": "delete"})).unwrap();
let arc = h(&s.execute("arc", &json!({"center": [0, 20], "radius": 2, "start": 0, "end": 180})).unwrap());
let r = s.execute("divide", &json!({"handle": arc, "segments": 2, "block": "TICK"})).unwrap();
let ins = s.doc().unwrap().entity(cadcraft_doc::Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()).unwrap().kind.clone();
assert!(matches!(ins, EntityKind::Insert(i) if near(i.insert.xy(), 0.0, 22.0) && (i.rotation - std::f64::consts::PI).abs() < 1e-6));
assert!(s.execute("divide", &json!({"handle": l, "segments": 3, "block": "NOPE"})).is_err());
assert!(s.execute("divide", &json!({"handle": l, "segments": 1})).is_err());
assert!(s.execute("measure", &json!({"handle": l, "length": 0})).is_err());
assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err());
}
#[test]
fn divide_and_measure_command_line() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.cmdline("divide 5,0 4").unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s).iter().filter(|k| **k == "Point").count(), 3);
s.cmdline("measure 9,0 2.5").unwrap();
assert!(s.running.is_none());
// Measured from the end nearest the pick.
assert!(matches!(last(&s), EntityKind::Point(p) if near(p.p.xy(), 2.5, 0.0)));
// Block option.
let tick = s.execute("circle", &json!({"center": [50, 50], "radius": 0.1})).unwrap();
s.execute("block", &json!({"name": "DOT", "base": [50, 50], "handles": [h(&tick)], "keep": "delete"})).unwrap();
s.cmdline("divide 6,0 b DOT n 2").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(matches!(last(&s), EntityKind::Insert(i) if near(i.insert.xy(), 5.0, 0.0) && i.rotation == 0.0));
// Invalid count re-prompts.
s.cmdline("divide 6,0 1").unwrap();
assert!(s.running.is_some());
s.cancel();
}
// ---------------- REVCLOUD ----------------
#[test]
fn revcloud_json() {
let mut s = Session::new();
s.execute("revcloud", &json!({"p1": [0, 0], "p2": [4, 2], "arcLength": 1})).unwrap();
match last(&s) {
EntityKind::LwPolyline(p) => {
assert!(p.closed);
assert_eq!(p.vertices.len(), 12);
assert!(p.vertices.iter().all(|v| v.bulge > 0.0));
}
k => panic!("{k:?}"),
}
s.execute("revcloud.polygonal", &json!({"points": [[0, 0], [0, 3], [3, 0]], "arcLength": 0.5})).unwrap();
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.vertices.len() > 10));
s.execute("revcloud.freehand", &json!({"freehand": [[0, 0], [5, 0], [5, 5], [0, 5]], "arcLength": 2, "style": "calligraphy"})).unwrap();
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.vertices.len() == 10 && p.vertices[0].end_width > 0.0));
let c = h(&s.execute("circle", &json!({"center": [10, 10], "radius": 1})).unwrap());
let n = s.doc().unwrap().model.len();
s.execute("revcloud", &json!({"handle": c, "arcLength": 0.5})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), n, "the circle is replaced");
assert!(s.execute("revcloud", &json!({"points": [[0, 0], [1, 1], [2, 2]]})).is_err());
assert!(s.execute("revcloud", &json!({"p1": [0, 0], "p2": [1e300, 1e300], "arcLength": 1e-300})).is_ok());
}
#[test]
fn revcloud_command_line() {
let mut s = Session::new();
s.cmdline("revcloud a 1").unwrap();
s.cmdline("0,0 4,2").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.vertices.len() == 12));
s.cmdline("revcloud.polygonal 0,0 4,0 4,3").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s), vec!["Polyline", "Polyline"]);
s.cmdline("revcloud.freehand 0,0 3,0 3,3 0,3").unwrap();
s.cmdline("").unwrap();
assert_eq!(kinds(&s).len(), 3);
// Object option converts a closed polyline.
s.execute("rectang", &json!({"p1": [10, 10], "p2": [12, 12]})).unwrap();
s.cmdline("revcloud o 10,11").unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s).len(), 4);
}
// ---------------- WIPEOUT ----------------
#[test]
fn wipeout_json_and_command_line() {
let mut s = Session::new();
s.execute("wipeout", &json!({"points": [[0, 0], [2, 0], [2, 2]]})).unwrap();
assert!(matches!(last(&s), EntityKind::Wipeout(w) if w.boundary.len() == 3));
let r = h(&s.execute("rectang", &json!({"p1": [5, 5], "p2": [6, 6]})).unwrap());
s.execute("wipeout", &json!({"handle": r, "erase": true})).unwrap();
assert_eq!(kinds(&s), vec!["Wipeout", "Wipeout"]);
s.execute("wipeout", &json!({"frames": "off"})).unwrap();
assert_eq!(s.doc().unwrap().header.i64("WIPEOUTFRAME", 1), 0);
assert!(s.execute("wipeout", &json!({"points": [[0, 0], [1, 1]]})).is_err());
assert!(s.execute("wipeout", &json!({"frames": "sideways"})).is_err());
s.cmdline("wipeout 0,0 3,0 3,3").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s).len(), 3);
s.execute("rectang", &json!({"p1": [20, 20], "p2": [22, 22]})).unwrap();
s.cmdline("wipeout p 20,21 y").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(kinds(&s), vec!["Wipeout"; 4]);
s.cmdline("wipeout f on").unwrap();
assert_eq!(s.doc().unwrap().header.i64("WIPEOUTFRAME", 0), 1);
}
// ---------------- XLINE ----------------
fn xline_dir(k: &EntityKind) -> (Vec2, Vec2) {
match k {
EntityKind::XLine(r) => (r.base.xy(), r.dir.xy()),
k => panic!("{k:?}"),
}
}
#[test]
fn xline_options_json() {
let mut s = Session::new();
s.execute("xline", &json!({"vertex": [0, 0], "start": [1, 0], "end": [0, 1]})).unwrap();
let (_, d) = xline_dir(&last(&s));
assert!(d.near(Vec2::new(1.0, 1.0).normalized(), 1e-9));
let l = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
s.execute("xline", &json!({"handle": l, "distance": 2, "side": [5, -1]})).unwrap();
let (b, d) = xline_dir(&last(&s));
assert!((b.y + 2.0).abs() < 1e-9 && d.near(Vec2::X, 1e-9));
s.execute("xline", &json!({"handle": l, "through": [3, 7]})).unwrap();
assert!(near(xline_dir(&last(&s)).0, 3.0, 7.0));
s.execute("xline", &json!({"base": [1, 1], "ver": true})).unwrap();
assert!(xline_dir(&last(&s)).1.near(Vec2::Y, 1e-9));
s.execute("xline", &json!({"base": [1, 1], "angle": 30})).unwrap();
s.execute("xline", &json!({"base": [1, 1], "through": [2, 2]})).unwrap();
assert!(s.execute("xline", &json!({"base": [1, 1], "through": [1, 1]})).is_err());
}
#[test]
fn xline_options_command_line() {
let mut s = Session::new();
s.cmdline("xline b 0,0 1,0 0,1 -1,0").unwrap();
s.cmdline("").unwrap();
assert_eq!(kinds(&s), vec!["Xline", "Xline"]);
s.cmdline("xline a 45 3,3").unwrap();
s.cmdline("").unwrap();
assert!(xline_dir(&last(&s)).1.near(Vec2::new(1.0, 1.0).normalized(), 1e-9));
s.execute("line", &json!({"points": [[20, 0], [30, 0]]})).unwrap();
s.cmdline("xline o 2 25,0 25,5").unwrap();
s.cmdline("").unwrap();
let (b, _) = xline_dir(&last(&s));
assert!((b.y - 2.0).abs() < 1e-9);
s.cmdline("xline o t 25,0 0,9").unwrap();
s.cmdline("").unwrap();
assert!(near(xline_dir(&last(&s)).0, 0.0, 9.0));
s.cmdline("xline a r 25,0 90 4,4").unwrap();
s.cmdline("").unwrap();
assert!(xline_dir(&last(&s)).1.near(Vec2::Y, 1e-9));
s.cmdline("xline h 0,7").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
}
// ---------------- RECTANG ----------------
fn rect_pts(s: &Session) -> Vec<Vec2> {
match last(s) {
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(),
k => panic!("{k:?}"),
}
}
#[test]
fn rectang_options_json() {
let mut s = Session::new();
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 3]})).unwrap();
assert!(near(rect_pts(&s)[2], 4.0, 3.0));
s.execute("rectang", &json!({"p1": [0, 0], "dimensions": [3, 2], "p2": [-1, -1]})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, -3.0, -2.0) && near(b.max, 0.0, 0.0));
s.execute("rectang", &json!({"p1": [0, 0], "area": 20, "length": 5})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.max, 5.0, 4.0));
s.execute("rectang", &json!({"p1": [0, 0], "dimensions": [2, 1], "rotation": 90})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, -1.0, 0.0) && near(b.max, 0.0, 2.0), "{b:?}");
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "fillet": 1, "width": 0.1, "thickness": 2})).unwrap();
assert_eq!(rect_pts(&s).len(), 8);
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0);
assert!(s.execute("rectang", &json!({"p1": [0, 0], "area": -1, "length": 1})).is_err());
assert!(s.execute("rectang", &json!({"p1": [0, 0], "p2": [0, 5]})).is_err());
}
#[test]
fn rectang_options_command_line() {
let mut s = Session::new();
s.cmdline("rectang 0,0 a 20 l 5").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 5.0, 4.0));
s.cmdline("rectang 0,0 a 12 w 3").unwrap();
assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 4.0, 3.0));
s.cmdline("rectang 0,0 d 3 2 -1,-1").unwrap();
assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).min, -3.0, -2.0));
s.cmdline("rectang 0,0 r 90 2,1").unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 0.0, 0.0) && near(b.max, 2.0, 1.0), "{b:?}");
s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap();
let pts = rect_pts(&s);
assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}");
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap();
match last(&s) {
EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8),
k => panic!("{k:?}"),
}
s.cmdline("rectang f 1 e 2 t 3 0,0 4,4").unwrap();
match &s.doc().unwrap().model.last().unwrap().kind {
EntityKind::LwPolyline(p) => assert!(p.elevation == 2.0 && p.vertices.len() == 8),
k => panic!("{k:?}"),
}
}
// ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ----------------
#[test]
fn poly3d_json_and_command_line() {
let mut s = Session::new();
s.execute("3dpoly", &json!({"points": [[0, 0, 0], [1, 0, 2], [1, 1, 3]], "closed": true})).unwrap();
assert!(matches!(last(&s), EntityKind::Polyline3d(p) if p.points.len() == 3 && p.points[2].z == 3.0 && p.closed));
assert!(s.execute("3dpoly", &json!({"points": [[0, 0]]})).is_err());
s.cmdline("3dpoly 0,0 1,0 1,1 u 2,2 c").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::Polyline3d(p) if p.points.len() == 3 && p.closed));
assert_eq!(s.state().unwrap().undo.len(), 2);
}
#[test]
fn mline_json_and_command_line() {
let mut s = Session::new();
let r = s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 10]], "scale": 2, "justification": "zero"})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
let ys: Vec<f64> =
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.vertices[0].p.y) } else { None }).collect();
assert!(ys.contains(&1.0) && ys.contains(&-1.0), "{ys:?}");
assert!(s.execute("mline", &json!({"points": [[0, 0], [1, 0]], "justification": "middle"})).is_err());
let mut s = Session::new();
s.cmdline("mline j b s 3 0,0 10,0 10,5").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
let ys: Vec<f64> =
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.vertices[0].p.y) } else { None }).collect();
assert_eq!(ys.len(), 2);
assert!(ys.contains(&0.0) && ys.contains(&3.0), "{ys:?}");
s.cmdline("mline 0,20 5,20 5,25 c").unwrap();
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.closed));
}
#[test]
fn helix_json_and_command_line() {
let mut s = Session::new();
s.execute("helix", &json!({"center": [0, 0], "baseRadius": 2, "topRadius": 1, "height": 6, "turns": 2})).unwrap();
match last(&s) {
EntityKind::Polyline3d(p) => {
assert!(near(p.points[0].xy(), 2.0, 0.0));
let top = p.points.last().unwrap();
assert!((top.z - 6.0).abs() < 1e-9 && near(top.xy(), 1.0, 0.0));
}
k => panic!("{k:?}"),
}
assert!(s.execute("helix", &json!({"center": [0, 0], "baseRadius": 1, "turns": 1e9})).is_err());
s.cmdline("helix 0,0 1 2 t 4 w cw 5").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
match last(&s) {
EntityKind::Polyline3d(p) => {
let top = p.points.last().unwrap();
assert!((top.z - 5.0).abs() < 1e-9 && near(top.xy(), 2.0, 0.0));
assert!(p.points[1].y < 0.0, "clockwise twist");
}
k => panic!("{k:?}"),
}
}
#[test]
fn spline_cv_json_and_command_line() {
let mut s = Session::new();
s.execute("spline.cv", &json!({"control": [[0, 0], [1, 2], [3, 2], [4, 0]], "degree": 2})).unwrap();
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.degree == 2 && sp.fit.is_empty()));
assert!(s.execute("spline.cv", &json!({"control": [[0, 0]]})).is_err());
s.cmdline("spline.cv d 3 0,0 1,1 2,0 3,1").unwrap();
s.cmdline("").unwrap();
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.control.len() == 4 && sp.degree == 3));
s.cmdline("spline.cv 0,0 1,1 2,0 c").unwrap();
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.closed));
}
// ---------------- CENTER MARK / CENTER LINE ----------------
#[test]
fn centermark_and_centerline() {
let mut s = Session::new();
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap());
let r = s.execute("centermark", &json!({"handle": c})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
let e = (**s.doc().unwrap().model.last().unwrap()).clone();
assert_eq!(e.common.linetype, "CENTER");
assert!(s.doc().unwrap().linetype("CENTER").is_some());
assert!(matches!(e.kind, EntityKind::Line(l) if (l.b.y - 2.12).abs() < 1e-9));
let a = s.execute("line", &json!({"points": [[10, 0], [20, 0]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
let b = s.execute("line", &json!({"points": [[20, 4], [10, 4]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
s.execute("centerline", &json!({"h1": a, "h2": b})).unwrap();
assert!(matches!(last(&s), EntityKind::Line(l) if (l.a.y - 2.0).abs() < 1e-9 && (l.a.x - 9.88).abs() < 1e-9));
assert!(s.execute("centerline", &json!({"h1": a, "h2": c})).is_err());
assert!(s.execute("centermark", &json!({"handle": a})).is_err());
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap();
s.execute("arc", &json!({"center": [10, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
s.cmdline("centermark 2,0 11,0").unwrap();
s.cmdline("").unwrap();
assert_eq!(kinds(&s).iter().filter(|k| **k == "Line").count(), 4);
s.execute("line", &json!({"points": [[0, 10], [10, 10]]})).unwrap();
s.execute("line", &json!({"points": [[0, 12], [10, 12]]})).unwrap();
s.cmdline("centerline 5,10 5,12").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::Line(l) if (l.a.y - 11.0).abs() < 1e-9));
}
#[test]
fn new_draw_commands_survive_garbage_input() {
let ids = [
"arc.sce",
"arc.sca",
"arc.scl",
"arc.sea",
"arc.sed",
"arc.ser",
"arc.cse",
"arc.csa",
"arc.csl",
"arc.continue",
"circle.cd",
"circle.2p",
"circle.3p",
"circle.ttr",
"circle.ttt",
"ellipse.axis",
"ellipse.arc",
"divide",
"measure",
"revcloud",
"revcloud.polygonal",
"revcloud.freehand",
"wipeout",
"3dpoly",
"mline",
"helix",
"spline.cv",
"centermark",
"centerline",
"xline",
"rectang",
];
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
for id in ids {
for p in [
json!({"start": [0, 0], "center": [0, 0], "end": [0, 0], "angle": 1e308, "length": -1e308, "radius": 0, "direction": [0, 0]}),
json!({"p1": [0, 0], "p2": [0, 0], "p3": [0, 0], "distance": -0.0, "diameter": 1e308, "radius": f64::MAX}),
json!({"handle": "100", "segments": 1e18, "length": 1e-320, "block": ""}),
json!({"points": [[0, 0], [1e308, 1e308], [-1e308, 1e308]], "arcLength": 1e-300, "scale": 1e308}),
json!({"center": [0, 0], "baseRadius": 1e308, "turns": 499, "height": 1e308}),
json!({"control": [[0, 0], [0, 0]], "degree": 1e9}),
json!({"p1": [0, 0], "dimensions": [1e308, -1e308], "rotation": 1e308, "area": 1e308, "length": 1e-308}),
json!({"h1": "100", "p1": [0, 0], "h2": "100", "p2": [0, 0], "h3": "100", "p3": [0, 0], "radius": 1}),
json!({"vertex": [0, 0], "start": [0, 0], "end": [0, 0]}),
] {
let _ = s.execute(id, &p);
}
// Interactive: garbage text and points.
s.start(id).unwrap();
for t in ["", "x", "1e999", "-5", "0,0", "0,0", "nan", "b", "a", "1", "2,2", ""] {
if s.running.is_none() {
break;
}
let _ = s.cmdline(t);
}
let _ = s.preview(Vec2::new(1e300, -1e300));
s.cancel();
let _ = s.input(Input::Cancel);
}
}
#[test]
fn arc_command_options() {
for (cmd, center) in [
("arc 1,0 0,1 -1,0", (0.0, 0.0)),
("arc c 0,0 1,0 0,1", (0.0, 0.0)),
("arc c 0,0 1,0 a 90", (0.0, 0.0)),
("arc c 0,0 1,0 l 1.41421356237", (0.0, 0.0)),
("arc 1,0 c 0,0 0,1", (0.0, 0.0)),
("arc 1,0 c 0,0 a 90", (0.0, 0.0)),
("arc 1,0 e 0,1 0,0", (0.0, 0.0)),
("arc 1,0 e 0,1 a 90", (0.0, 0.0)),
("arc 1,0 e 0,1 d 90", (0.0, 0.0)),
("arc 1,0 e 0,1 r 1", (0.0, 0.0)),
] {
let mut s = Session::new();
s.cmdline(cmd).unwrap();
assert!(s.running.is_none(), "{cmd}: {}", s.prompt_text());
let a = as_arc(&last(&s));
assert!(near(a.center, center.0, center.1) && (a.radius - 1.0).abs() < 1e-6, "{cmd}: {a:?}");
}
// Enter at the first prompt continues from the last line.
let mut s = Session::new();
s.cmdline("line 0,0 1,0").unwrap();
s.cmdline("").unwrap();
s.cmdline("arc").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("end point"), "{}", s.prompt_text());
s.cmdline("2,1").unwrap();
assert!(near(as_arc(&last(&s)).center, 1.0, 1.0));
assert!(s.prompt_text() == "Command:");
}
+5
View File
@@ -9,10 +9,15 @@ use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
/// File format hooks (installed by the app so the engine stays I/O-agnostic and wasm-safe).
pub struct IoHooks {
pub read: fn(&[u8], &str) -> std::result::Result<Drawing, String>,
pub write: fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>,
/// PLOT / EXPORTPDF (optional: builds without a plotter report "not available").
pub plot: Option<PlotHook>,
}
static IO: OnceLock<IoHooks> = OnceLock::new();
+107
View File
@@ -0,0 +1,107 @@
//! `grip.*` commands: the undoable, scriptable form of grip editing (see `crate::grips`).
use cadcraft_doc::Handle;
use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::curves;
use super::*;
use crate::grips::{GripMode, mode_matrix, stretch_grip};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("grip.move", "Grip Stretch", run_grip_move)
.params("{handle, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)"),
CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle, index, to}"),
CommandSpec::new("grip.rotate", "Grip Rotate", run_grip_rotate).params("{handles?, base | baseHandle+index, angle (degrees) | to, copy?}"),
CommandSpec::new("grip.scale", "Grip Scale", run_grip_scale).params("{handles?, base | baseHandle+index, factor | to, copy?}"),
CommandSpec::new("grip.mirror", "Grip Mirror", run_grip_mirror)
.params("{handles?, base | baseHandle+index, to (second mirror point), copy?}"),
]
}
fn index_param(cmd: &str, p: &Value) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`index` (grip number) is required"))?;
usize::try_from(i).map_err(|_| bad(cmd, "index out of range"))
}
fn stretch(s: &mut Session, cmd: &str, p: &Value) -> Result<Value> {
let h = curves::handle_param(p, "handle")
.or_else(|| targets(s, p).ok().and_then(|t| t.first().copied()))
.ok_or_else(|| bad(cmd, "`handle` is required"))?;
let i = index_param(cmd, p)?;
let to = point_req(cmd, p, "to")?;
if curves::is_locked(s, h) {
return Err(EngineError::Other("The object is on a locked layer.".into()));
}
let e = curves::entity(s, h)?;
let k = stretch_grip(&e.kind, i, to).ok_or_else(|| bad(cmd, "no such grip, or the result would be degenerate"))?;
s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
Ok(json!({ "handle": h.hex() }))
}
/// Base point: `base`, or grip `index` of `baseHandle` (default: the first target).
fn base_point(s: &Session, cmd: &str, p: &Value, hs: &[Handle]) -> Result<Vec2> {
if let Some(b) = point_param(p, "base") {
return Ok(b);
}
let h = curves::handle_param(p, "baseHandle")
.or_else(|| hs.first().copied())
.ok_or_else(|| bad(cmd, "`base` or `baseHandle` + `index` is required"))?;
let i = index_param(cmd, p)?;
let g = curves::entity(s, h)?.kind.grips();
g.get(i).copied().ok_or_else(|| bad(cmd, "no such grip"))
}
fn apply(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Value> {
let r = super::modify::transform_entities(s, hs, m, copy)?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
fn run_grip_stretch(s: &mut Session, p: &Value) -> Result<Value> {
stretch(s, "grip.stretch", p)
}
fn run_grip_move(s: &mut Session, p: &Value) -> Result<Value> {
let mode_move = str_param(p, "mode").is_some_and(|m| m.eq_ignore_ascii_case("move")) || (p.get("index").is_none() && p.get("base").is_some());
if !mode_move {
return stretch(s, "grip.move", p);
}
let hs = targets(s, p)?;
let base = base_point(s, "grip.move", p, &hs)?;
let to = point_req("grip.move", p, "to")?;
let m = mode_matrix(GripMode::Move, base, to).unwrap_or(Mat3::IDENTITY);
apply(s, &hs, &m, bool_or(p, "copy", false))
}
fn run_grip_rotate(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let base = base_point(s, "grip.rotate", p, &hs)?;
let m = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) {
Some(a) => Mat3::rotate_about(base, a.to_radians()),
None => mode_matrix(GripMode::Rotate, base, point_req("grip.rotate", p, "to")?).ok_or_else(|| bad("grip.rotate", "invalid point"))?,
};
apply(s, &hs, &m, bool_or(p, "copy", false))
}
fn run_grip_scale(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let base = base_point(s, "grip.scale", p, &hs)?;
let m = match p.get("factor").and_then(Value::as_f64).filter(|a| a.is_finite()) {
Some(f) if f > 0.0 => Mat3::scale_about(base, f),
Some(_) => return Err(bad("grip.scale", "factor must be positive")),
None => {
mode_matrix(GripMode::Scale, base, point_req("grip.scale", p, "to")?).ok_or_else(|| bad("grip.scale", "scale factor would be zero"))?
}
};
apply(s, &hs, &m, bool_or(p, "copy", false))
}
fn run_grip_mirror(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let base = base_point(s, "grip.mirror", p, &hs)?;
let to = point_req("grip.mirror", p, "to")?;
let m = mode_matrix(GripMode::Mirror, base, to).ok_or_else(|| bad("grip.mirror", "the mirror line needs two distinct points"))?;
apply(s, &hs, &m, bool_or(p, "copy", false))
}
+887
View File
@@ -0,0 +1,887 @@
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy),
//! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF.
//!
//! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays
//! I/O-agnostic.
use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Layout, PageSetup, Space, Viewport};
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::file::{base64_encode, io};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("layout", "Layout", run_layout)
.alias(&["lo"])
.params("{option: new|copy|delete|rename|set|list, name?, to?}"),
CommandSpec::new("layout.new", "New Layout", run_new)
.menu(&["Insert", "Layout", "New Layout"])
.params("{name?, viewport?: bool (default true)} → {name, viewport}"),
CommandSpec::new("layout.delete", "Delete Layout", run_delete).params("{name}"),
CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),
CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
CommandSpec::new("mview", "Viewports", run_mview)
.alias(&["mv"])
.params("{p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, layout?}")
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
CommandSpec::new("vports", "New Viewports...", run_mview)
.menu(&["View", "Viewports", "New Viewports..."])
.params("{count?: 1-4, arrangement?, p1?, p2?, layout?}")
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
CommandSpec::new("vports.1", "1 Viewport", |s, p| run_mview_n(s, p, 1))
.menu(&["View", "Viewports", "1 Viewport"])
.params("{p1?, p2?, layout?}")
.interactive(|_| Ok(Box::new(MviewM::new(1, false)))),
CommandSpec::new("vports.2", "2 Viewports", |s, p| run_mview_n(s, p, 2))
.menu(&["View", "Viewports", "2 Viewports"])
.params("{p1?, p2?, arrangement?: vertical|horizontal, layout?}")
.interactive(|_| Ok(Box::new(MviewM::new(2, false)))),
CommandSpec::new("vports.3", "3 Viewports", |s, p| run_mview_n(s, p, 3))
.menu(&["View", "Viewports", "3 Viewports"])
.params("{p1?, p2?, arrangement?: right|left|above|below|vertical|horizontal, layout?}")
.interactive(|_| Ok(Box::new(MviewM::new(3, false)))),
CommandSpec::new("vports.4", "4 Viewports", |s, p| run_mview_n(s, p, 4))
.menu(&["View", "Viewports", "4 Viewports"])
.params("{p1?, p2?, layout?}")
.interactive(|_| Ok(Box::new(MviewM::new(4, false)))),
CommandSpec::new("viewport.set", "Viewport Properties", run_viewport_set).params(
"{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer]}",
),
CommandSpec::new("vplayer", "Viewport Layer Freeze", run_viewport_set).params("{handle?, freeze?: [layer], thaw?: [layer]}"),
CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)
.menu(&["File", "Page Setup Manager..."])
.params(
"{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?}",
),
CommandSpec::new("plot", "Print...", |s, p| run_plot(s, p, "plot"))
.menu(&["File", "Print..."])
.key("Cmd+P")
.alias(&["print"])
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?, lineweights?: bool}")
.noundo(),
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, p, "exportpdf"))
.menu(&["File", "Export to PDF..."])
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, lineweights?}")
.noundo(),
]
}
// ---------- layouts ----------
/// Characters AutoCAD does not allow in layout names.
const BAD_CHARS: &[char] = &['<', '>', '/', '\\', '"', ':', ';', '?', '*', '|', ',', '=', '`'];
fn check_name(cmd: &str, d: &Drawing, name: &str, except: Option<&str>) -> Result<String> {
let n = name.trim();
if n.is_empty() {
return Err(bad(cmd, "layout name is empty"));
}
if n.chars().count() > 255 {
return Err(bad(cmd, "layout name is longer than 255 characters"));
}
if n.contains(BAD_CHARS) || n.chars().any(char::is_control) {
return Err(bad(cmd, format!("invalid layout name `{n}`")));
}
if n.eq_ignore_ascii_case("model") {
return Err(bad(cmd, "`Model` is reserved"));
}
if d.layouts.iter().any(|l| l.name.eq_ignore_ascii_case(n) && except.is_none_or(|e| !l.name.eq_ignore_ascii_case(e))) {
return Err(bad(cmd, format!("layout `{n}` already exists")));
}
Ok(n.to_string())
}
fn layout_name(cmd: &str, d: &Drawing, name: &str) -> Result<String> {
d.layout(name).map(|l| l.name.clone()).ok_or_else(|| bad(cmd, format!("no layout `{name}`")))
}
fn next_layout_name(d: &Drawing) -> String {
(1..=10_000).map(|i| format!("Layout{i}")).find(|n| d.layout(n).is_none()).unwrap_or_else(|| "Layout".into())
}
/// The page setup new layouts get: ANSI A landscape (imperial) or ISO A4 landscape (metric).
fn default_page(d: &Drawing) -> PageSetup {
let mut p = PageSetup::default();
if cadcraft_render::paper::paper_unit_mm(d) == 1.0
&& let Some(a4) = cadcraft_render::paper_size("A4")
{
p.paper = a4.name.into();
p.width_mm = a4.width_mm;
p.height_mm = a4.height_mm;
}
p
}
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc()?;
let name = match str_param(p, "name") {
Some(n) => check_name("layout.new", d, n, None)?,
None => next_layout_name(d),
};
let page = default_page(d);
let order = d.layouts.iter().map(|l| l.tab_order).max().unwrap_or(0).saturating_add(1);
s.doc_mut()?.layouts.push(Layout { page, ..Layout::new(&name, order) });
let mut vp = Value::Null;
if bool_or(p, "viewport", true) {
// AutoCAD behaviour: one viewport over the printable area, zoomed to the model extents.
let space = Space::Paper(name.clone());
let rect = printable(s, &name)?;
if let Some(h) = add_viewports(s, &space, rect, 1, "")?.first() {
vp = json!(h.hex());
}
}
Ok(json!({ "name": name, "viewport": vp }))
}
fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("layout.delete", "`name` is required"))?;
let name = layout_name("layout.delete", s.doc()?, name)?;
if s.doc()?.layouts.len() <= 1 {
return Err(bad("layout.delete", "a drawing must keep at least one layout"));
}
s.doc_mut()?.layouts.retain(|l| l.name != name);
let gone = Space::Paper(name.clone());
let st = s.state_mut()?;
st.views.retain(|(sp, _)| sp != &gone);
if st.space == gone {
st.space = Space::Model;
st.selection.clear();
}
s.touch();
Ok(json!({ "deleted": name }))
}
fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
let from = match (str_param(p, "from"), s.space()) {
(Some(f), _) => f.to_string(),
(None, Space::Paper(n)) => n,
(None, Space::Model) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
};
let from = layout_name("layout.rename", s.doc()?, &from)?;
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
let to = check_name("layout.rename", s.doc()?, to, Some(&from))?;
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == from) {
l.name = to.clone();
}
let (old, new) = (Space::Paper(from.clone()), Space::Paper(to.clone()));
let st = s.state_mut()?;
for (sp, _) in st.views.iter_mut() {
if *sp == old {
*sp = new.clone();
}
}
if st.space == old {
st.space = new;
}
s.touch();
Ok(json!({ "from": from, "to": to }))
}
fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
let from = str_param(p, "from").ok_or_else(|| bad("layout.copy", "`from` is required"))?;
let from = layout_name("layout.copy", s.doc()?, from)?;
let to = match str_param(p, "to") {
Some(t) => check_name("layout.copy", s.doc()?, t, None)?,
None => {
let d = s.doc()?;
(2..=10_000).map(|i| format!("{from} ({i})")).find(|n| d.layout(n).is_none()).ok_or_else(|| bad("layout.copy", "no free name"))?
}
};
let d = s.doc_mut()?;
let Some(src) = d.layouts.iter().find(|l| l.name == from).cloned() else { return Err(bad("layout.copy", "no such layout")) };
let order = d.layouts.iter().map(|l| l.tab_order).max().unwrap_or(0).saturating_add(1);
let mut store = EntityStore::new();
for e in src.entities.iter() {
let h = d.new_handle();
store.push(Entity { handle: h, ..Entity::clone(e) });
}
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
Ok(json!({ "name": to }))
}
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
let mut ls: Vec<&Layout> = d.layouts.iter().collect();
ls.sort_by_key(|l| l.tab_order);
let current = s.space();
Ok(Value::Array(
ls.iter()
.map(|l| {
json!({
"name": l.name,
"tabOrder": l.tab_order,
"current": current == Space::Paper(l.name.clone()),
"page": serde_json::to_value(&l.page).unwrap_or(Value::Null),
"viewports": l.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.id != 1)).map(|e| e.handle.hex()).collect::<Vec<_>>(),
})
})
.collect(),
))
}
/// `-LAYOUT`-style dispatcher.
fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase();
match opt.as_str() {
"new" | "n" => run_new(s, p),
"copy" | "c" => {
let mut q = p.clone();
if q.get("from").is_none()
&& let (Some(n), Some(o)) = (p.get("name").cloned(), q.as_object_mut())
{
o.insert("from".into(), n);
}
run_copy(s, &q)
}
"delete" | "d" => run_delete(s, p),
"rename" | "r" => {
let mut q = p.clone();
if q.get("from").is_none()
&& let (Some(n), Some(o)) = (p.get("name").cloned(), q.as_object_mut())
{
o.insert("from".into(), n);
}
run_rename(s, &q)
}
"set" | "s" => {
let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?;
(set.run)(s, p)
}
"list" | "?" => run_list(s, p),
_ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")),
}
}
// ---------- viewports ----------
/// The printable area of a layout, in paper units.
fn printable(s: &Session, layout: &str) -> Result<Bounds2> {
cadcraft_render::sheet(s.doc()?, layout).map(|sh| sh.printable).ok_or_else(|| bad("mview", format!("no layout `{layout}`")))
}
/// The layout a viewport command acts on: `layout` param or the current layout.
fn target_layout(s: &Session, cmd: &str, p: &Value) -> Result<Space> {
if let Some(n) = str_param(p, "layout") {
return Ok(Space::Paper(layout_name(cmd, s.doc()?, n)?));
}
match s.space() {
Space::Model => Err(bad(cmd, "** Command not allowed in Model Tab ** (switch to a layout or pass `layout`)")),
sp => Ok(sp),
}
}
/// Split a rectangle into `count` viewport rectangles (AutoCAD's VPORTS arrangements).
fn split(r: Bounds2, count: usize, arrangement: &str) -> Vec<Bounds2> {
let (x0, y0, x1, y1) = (r.min.x, r.min.y, r.max.x, r.max.y);
let xm = (x0 + x1) / 2.0;
let ym = (y0 + y1) / 2.0;
let b = |a: f64, b: f64, c: f64, d: f64| Bounds2::new(Vec2::new(a, b), Vec2::new(c, d));
let a = arrangement.to_ascii_lowercase();
match count {
2 if a.starts_with('h') => vec![b(x0, ym, x1, y1), b(x0, y0, x1, ym)],
2 => vec![b(x0, y0, xm, y1), b(xm, y0, x1, y1)],
3 => {
let (t1, t2) = (x0 + (x1 - x0) / 3.0, x0 + 2.0 * (x1 - x0) / 3.0);
let (u1, u2) = (y0 + (y1 - y0) / 3.0, y0 + 2.0 * (y1 - y0) / 3.0);
match a.as_str() {
"left" | "l" => vec![b(x0, y0, xm, y1), b(xm, ym, x1, y1), b(xm, y0, x1, ym)],
"above" | "a" => vec![b(x0, ym, x1, y1), b(x0, y0, xm, ym), b(xm, y0, x1, ym)],
"below" | "b" => vec![b(x0, y0, x1, ym), b(x0, ym, xm, y1), b(xm, ym, x1, y1)],
"vertical" | "v" => vec![b(x0, y0, t1, y1), b(t1, y0, t2, y1), b(t2, y0, x1, y1)],
"horizontal" | "h" => vec![b(x0, u2, x1, y1), b(x0, u1, x1, u2), b(x0, y0, x1, u1)],
// Right (the default): two stacked on the left, the large one on the right.
_ => vec![b(x0, ym, xm, y1), b(x0, y0, xm, ym), b(xm, y0, x1, y1)],
}
}
4 => vec![b(x0, ym, xm, y1), b(xm, ym, x1, y1), b(x0, y0, xm, ym), b(xm, y0, x1, ym)],
_ => vec![r],
}
}
/// View height that shows `ext` in a viewport of the given size (zoom extents).
fn fit_view(d: &Drawing, w: f64, h: f64) -> (Vec2, f64) {
let mut ext = d.extents(&Space::Model);
if ext.is_empty() {
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
ext = Bounds2::new(lo, hi);
}
let aspect = (w / h.max(1e-12)).max(1e-12);
let vh = (ext.height().max(ext.width() / aspect) * 1.02).max(1e-9);
let c = ext.center();
if vh.is_finite() && c.is_finite() { (c, vh) } else { (Vec2::ZERO, 1.0) }
}
/// Add `count` viewports tiling `rect` in a layout, each zoomed to the model extents.
fn add_viewports(s: &mut Session, space: &Space, rect: Bounds2, count: usize, arrangement: &str) -> Result<Vec<Handle>> {
if !(1..=4).contains(&count) {
return Err(bad("mview", "count must be 1, 2, 3 or 4"));
}
if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 {
return Err(bad("mview", "the viewport has no area"));
}
if rect.width() > 1e7 || rect.height() > 1e7 {
return Err(bad("mview", "the viewport is too large"));
}
let common = s.current_common();
let d = s.doc_mut()?;
let mut id = d
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None })
.max()
.unwrap_or(1)
.max(1);
let mut out = Vec::new();
for r in split(rect, count, arrangement) {
let (view_center, view_height) = fit_view(d, r.width(), r.height());
id = id.saturating_add(1);
let vp = Viewport {
center: r.center().to3(0.0),
width: r.width(),
height: r.height(),
view_center,
view_height,
id,
locked: false,
frozen_layers: Vec::new(),
};
out.push(d.add(space, common.clone(), EntityKind::Viewport(vp))?);
}
Ok(out)
}
fn mview(s: &mut Session, p: &Value, count: usize, cmd: &str) -> Result<Value> {
let space = target_layout(s, cmd, p)?;
let Space::Paper(name) = &space else { return Err(bad(cmd, "not a layout")) };
let rect = match (point_param(p, "p1"), point_param(p, "p2")) {
(Some(a), Some(b)) => Bounds2::new(a, b),
_ => printable(s, name)?,
};
let arrangement = str_param(p, "arrangement").unwrap_or("");
let hs = add_viewports(s, &space, rect, count, arrangement)?;
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
fn run_mview(s: &mut Session, p: &Value) -> Result<Value> {
let count = match p.get("count") {
None | Some(Value::Null) => 1,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("mview", "count must be 1, 2, 3 or 4"))?,
};
mview(s, p, count, "mview")
}
fn run_mview_n(s: &mut Session, p: &Value, n: usize) -> Result<Value> {
mview(s, p, n, "vports")
}
/// MVIEW / VPORTS prompts: first corner (or Fit, or a viewport count), opposite corner.
struct MviewM {
count: usize,
/// Offer the 2/3/4 keywords (MVIEW) rather than a fixed count (View > Viewports > n).
choose: bool,
first: Option<Vec2>,
}
impl MviewM {
fn new(count: usize, choose: bool) -> Self {
MviewM { count, choose, first: None }
}
fn create(&self, s: &mut Session, rect: Option<Bounds2>) -> Result<Step> {
let mut p = json!({ "count": self.count });
if let (Some(r), Some(o)) = (rect, p.as_object_mut()) {
o.insert("p1".into(), json!([r.min.x, r.min.y]));
o.insert("p2".into(), json!([r.max.x, r.max.y]));
}
let r = mview(s, &p, self.count, "mview")?;
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
s.echo(format!("{n} viewport(s) created, zoomed to the model extents."));
Ok(Step::Done)
}
}
impl Interactive for MviewM {
fn name(&self) -> &'static str {
"MVIEW"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if s.space() == Space::Model {
s.echo("** Command not allowed in Model Tab ** (switch to a layout first)");
return Ok(Step::Done);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.first {
None if self.choose && self.count == 1 => {
Prompt::new("Specify corner of viewport", Accept::POINT).kw(&["Fit", "2", "3", "4"]).default("Fit")
}
None => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.first, i) {
(None, Input::Enter) => self.create(s, None),
(None, Input::Keyword(k)) | (None, Input::Text(k)) => match k.trim().to_ascii_lowercase().as_str() {
"fit" | "f" => self.create(s, None),
n @ ("2" | "3" | "4") if self.choose => {
self.count = n.parse().unwrap_or(1);
Ok(Step::Continue)
}
_ => Err(crate::EngineError::Other("Requires a point or option keyword.".into())),
},
(None, Input::Point(p)) => {
self.first = Some(p);
Ok(Step::Continue)
}
(Some(a), Input::Point(b)) => self.create(s, Some(Bounds2::new(a, b))),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
let Some(a) = self.first else { return Vec::new() };
split(Bounds2::new(a, c), self.count, "")
.into_iter()
.map(|r| super::helpers::lwpoly(super::helpers::rect_vertices(r.min, r.max), true))
.collect()
}
}
/// A plot scale: a number (paper units per model unit) or `"a:b"` / `"a/b"`.
fn parse_scale(v: &Value) -> Option<f64> {
let s = match v {
Value::Number(n) => n.as_f64(),
Value::String(t) => {
let t = t.trim();
match t.split_once([':', '/']) {
Some((a, b)) => {
let a: f64 = a.trim().parse().ok()?;
let b: f64 = b.trim().parse().ok()?;
Some(a / b)
}
None => t.trim_end_matches(['x', 'X']).parse().ok(),
}
}
_ => None,
}?;
(s.is_finite() && s > 0.0).then_some(s)
}
fn layer_list(p: &Value, key: &str) -> Vec<String> {
match p.get(key) {
Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(10_000).collect(),
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(10_000).collect(),
_ => Vec::new(),
}
}
fn viewport_json(h: Handle, v: &Viewport) -> Value {
json!({
"handle": h.hex(),
"id": v.id,
"center": [v.center.x, v.center.y],
"width": v.width,
"height": v.height,
"viewCenter": [v.view_center.x, v.view_center.y],
"viewHeight": v.view_height,
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
"locked": v.locked,
"frozenLayers": v.frozen_layers,
})
}
fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "viewport.set";
let d = s.doc()?;
let hs: Vec<Handle> =
targets(s, p)?.into_iter().filter(|h| d.entity(*h).is_some_and(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.id != 1))).collect();
if hs.is_empty() {
return Err(bad(cmd, "no viewport given (pass `handle` or select a viewport)"));
}
let scale = match p.get("scale") {
Some(v) => Some(parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be a positive number or \"a:b\""))?),
None => None,
};
let view_height = match p.get("viewHeight") {
Some(v) => Some(v.as_f64().filter(|x| x.is_finite() && *x > 0.0).ok_or_else(|| bad(cmd, "`viewHeight` must be positive"))?),
None => None,
};
let center = match p.get("center") {
Some(v) => Some(point_value(v).ok_or_else(|| bad(cmd, "`center` must be [x, y]"))?),
None => None,
};
let locked = p.get("locked").and_then(Value::as_bool);
let freeze = layer_list(p, "freeze");
let thaw = layer_list(p, "thaw");
let changes_view = scale.is_some() || view_height.is_some() || center.is_some();
let mut out = Vec::new();
for h in hs {
let Some(EntityKind::Viewport(cur)) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
if changes_view && cur.locked && locked != Some(false) {
return Err(bad(cmd, format!("viewport {} is locked", h.hex())));
}
let mut v = cur;
if let Some(sc) = scale {
let vh = v.height / sc;
if vh.is_finite() && vh > 0.0 {
v.view_height = vh;
}
}
if let Some(vh) = view_height {
v.view_height = vh;
}
if let Some(c) = center {
v.view_center = c;
}
if let Some(l) = locked {
v.locked = l;
}
for f in &freeze {
if !v.frozen_layers.iter().any(|x| x.eq_ignore_ascii_case(f)) {
v.frozen_layers.push(f.clone());
}
}
v.frozen_layers.retain(|x| !thaw.iter().any(|t| t.eq_ignore_ascii_case(x)));
out.push(viewport_json(h, &v));
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::Viewport(v)).map_err(|e| bad(cmd, e.to_string()))?;
}
Ok(Value::Array(out))
}
// ---------- page setup ----------
fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "pagesetup";
let keys = ["paper", "width", "height", "landscape", "margins", "lineweights", "plotArea", "scale", "scaleToFit", "center", "plotStyleTable"];
let changes = keys.iter().any(|k| p.get(k).is_some());
let name = match (str_param(p, "layout"), s.space()) {
(Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?),
(None, Space::Paper(n)) => Some(n),
(None, Space::Model) => None,
};
let Some(name) = name else {
if changes {
return Err(bad(cmd, "pass `layout` (model-space page setups are not stored yet)"));
}
// Report every layout's page setup.
let d = s.doc()?;
return Ok(json!({
"layouts": d.layouts.iter().map(|l| json!({"name": l.name, "page": serde_json::to_value(&l.page).unwrap_or(Value::Null)})).collect::<Vec<_>>(),
"papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::<Vec<_>>(),
}));
};
let mut page = s.doc()?.layout(&name).map(|l| l.page.clone()).unwrap_or_default();
if let Some(paper) = p.get("paper") {
let n = paper.as_str().ok_or_else(|| bad(cmd, "`paper` must be a paper name"))?;
let ps = cadcraft_render::paper_size(n).ok_or_else(|| {
let known: Vec<&str> = cadcraft_render::PAPER_SIZES.iter().filter_map(|p| p.aliases.first().copied()).collect();
bad(cmd, format!("unknown paper size `{n}` (known: {})", known.join(", ")))
})?;
page.paper = ps.name.into();
page.width_mm = ps.width_mm;
page.height_mm = ps.height_mm;
}
let mm = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0 && *v <= 5080.0);
match (mm("width"), mm("height")) {
(Some(w), Some(h)) => {
page.paper = format!("User ({w:.2} x {h:.2} MM)");
page.width_mm = w.min(h);
page.height_mm = w.max(h);
}
(None, None) if p.get("width").is_none() && p.get("height").is_none() => {}
_ => return Err(bad(cmd, "`width` and `height` (mm, 0-5080) are both required for a custom size")),
}
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
page.landscape = l;
}
if let Some(m) = p.get("margins") {
let a: Vec<f64> = m.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
match a[..] {
[l, b, r, t] if a.iter().all(|v| v.is_finite() && (0.0..=1000.0).contains(v)) => page.margins_mm = [l, b, r, t],
_ => return Err(bad(cmd, "`margins` must be [left, bottom, right, top] in mm")),
}
}
if let Some(v) = p.get("lineweights").and_then(Value::as_bool) {
page.lineweights = v;
}
if let Some(v) = p.get("scaleToFit").and_then(Value::as_bool) {
page.scale_to_fit = v;
}
if let Some(v) = p.get("center").and_then(Value::as_bool) {
page.center = v;
}
if let Some(v) = p.get("scale") {
page.scale = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive"))?;
}
if let Some(v) = str_param(p, "plotArea") {
let v = v.to_ascii_lowercase();
if !["layout", "extents", "display", "window", "limits"].contains(&v.as_str()) {
return Err(bad(cmd, "`plotArea` must be layout, extents, display, window or limits"));
}
page.plot_area = v;
}
if let Some(v) = str_param(p, "plotStyleTable") {
page.plot_style_table = v.chars().take(260).collect();
}
if changes {
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) {
l.page = page.clone();
}
s.touch();
}
Ok(json!({ "layout": name, "page": serde_json::to_value(&page).unwrap_or(Value::Null) }))
}
// ---------- plotting ----------
fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let hook = io().and_then(|h| h.plot).ok_or_else(|| bad(cmd, "plotting is not available in this build"))?;
let space = match str_param(p, "layout") {
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
Some(n) => Space::Paper(layout_name(cmd, s.doc()?, n)?),
None => s.space(),
};
let opts = if p.is_object() { p.clone() } else { json!({}) };
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
let layout = match &space {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
};
match str_param(p, "path") {
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
{
let path = _path;
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout }))
}
#[cfg(target_arch = "wasm32")]
Err(bad(cmd, "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))
}
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout })),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_model() -> Session {
let mut s = Session::new();
s.cmdline("line 0,0 100,0").unwrap();
s.cmdline("").unwrap();
s.cmdline("circle 50,25 20").unwrap();
s
}
fn viewports(s: &Session, layout: &str) -> Vec<(Handle, Viewport)> {
s.doc()
.unwrap()
.layout(layout)
.unwrap()
.entities
.iter()
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((e.handle, v.clone())) } else { None })
.collect()
}
#[test]
fn new_layout_gets_viewport_zoomed_to_extents() {
let mut s = session_with_model();
let r = s.execute("layout.new", &json!({})).unwrap();
assert_eq!(r["name"], "Layout3");
let vps = viewports(&s, "Layout3");
assert_eq!(vps.len(), 1);
let (_, v) = &vps[0];
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout3").unwrap();
assert!((v.width - sheet.printable.width()).abs() < 1e-9 && (v.height - sheet.printable.height()).abs() < 1e-9);
assert!(v.id >= 2);
// Model extents (0,0)..(100,45) are centred and fully visible.
assert!(v.view_center.near(Vec2::new(50.0, 22.5), 1e-6));
let model_w = v.view_height * v.width / v.height;
assert!(model_w >= 100.0 && v.view_height >= 45.0);
// The render shows model geometry inside the viewport only.
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout3".into()), &cadcraft_render::Options::default());
let rect = Bounds2::new(v.center.xy() - Vec2::new(v.width, v.height) / 2.0, v.center.xy() + Vec2::new(v.width, v.height) / 2.0).expand(1e-9);
let content: Vec<_> = list.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).collect();
assert!(content.len() >= 2);
assert!(content.iter().all(|p| list.points(p).iter().all(|q| rect.contains(*q))));
assert!(list.sheet.is_some());
// Undo removes the layout.
s.undo().unwrap();
assert!(s.doc().unwrap().layout("Layout3").is_none());
}
#[test]
fn rename_copy_delete() {
let mut s = session_with_model();
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
s.execute("layout.rename", &json!({"to": "Sheet A"})).unwrap();
assert_eq!(s.space(), Space::Paper("Sheet A".into()));
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "Layout2"})).is_err(), "duplicate");
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "a/b"})).is_err(), "bad char");
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "model"})).is_err(), "reserved");
s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
let r = s.execute("layout.copy", &json!({"from": "Sheet A"})).unwrap();
assert_eq!(r["name"], "Sheet A (2)");
let a = viewports(&s, "Sheet A");
let b = viewports(&s, "Sheet A (2)");
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert_ne!(a[0].0, b[0].0, "copied entities get new handles");
s.execute("layout.delete", &json!({"name": "sheet a"})).unwrap();
assert_eq!(s.space(), Space::Model, "deleting the current layout returns to Model");
s.execute("layout", &json!({"option": "delete", "name": "Layout2"})).unwrap();
assert!(s.execute("layout.delete", &json!({"name": "Sheet A (2)"})).is_err(), "last layout stays");
let l = s.execute("layout", &json!({"option": "list"})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 1);
assert!(s.execute("layout.delete", &json!({"name": "Model"})).is_err());
}
#[test]
fn mview_arrangements_and_interactive() {
let mut s = session_with_model();
assert!(s.execute("mview", &json!({"p1": [0, 0], "p2": [1, 1]})).is_err(), "not in model space");
s.execute("mview", &json!({"layout": "Layout2", "count": 4})).unwrap();
assert_eq!(viewports(&s, "Layout2").len(), 4);
s.execute("vports.3", &json!({"layout": "Layout2", "p1": [0, 0], "p2": [9, 6]})).unwrap();
let v = viewports(&s, "Layout2");
assert_eq!(v.len(), 7);
let total: f64 = v[4..].iter().map(|(_, v)| v.width * v.height).sum();
assert!((total - 54.0).abs() < 1e-9, "three viewports tile the window");
// Interactive MVIEW: two corners.
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
s.cmdline("mview").unwrap();
s.cmdline("1,1").unwrap();
s.cmdline("6,4").unwrap();
assert!(s.running.is_none());
let v = viewports(&s, "Layout1");
assert_eq!(v.len(), 1);
assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9);
// MVIEW 2 → Fit (Enter).
s.cmdline("mview").unwrap();
s.cmdline("2").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(viewports(&s, "Layout1").len(), 3);
// One undo step per MVIEW.
s.undo().unwrap();
assert_eq!(viewports(&s, "Layout1").len(), 1);
// In model space the interactive command ends at once.
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
s.cmdline("vports.4").unwrap();
assert!(s.running.is_none());
}
#[test]
fn viewport_scale_lock_and_layers() {
let mut s = session_with_model();
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
let r = s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
let h = r["viewports"][0].as_str().unwrap().to_string();
let out = s.execute("viewport.set", &json!({"handle": h, "scale": "1:20", "center": [50, 20]})).unwrap();
assert!((out[0]["viewHeight"].as_f64().unwrap() - 60.0).abs() < 1e-9);
assert!((out[0]["scale"].as_f64().unwrap() - 0.05).abs() < 1e-12);
s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap();
assert!(s.execute("viewport.set", &json!({"handle": h, "scale": 0.1})).is_err(), "locked");
s.execute("viewport.set", &json!({"handle": h, "scale": 0.1, "locked": false})).unwrap();
s.execute("vplayer", &json!({"handle": h, "freeze": ["0", "Walls"]})).unwrap();
s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap();
let v = viewports(&s, "Layout1");
assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]);
assert!((v[0].1.view_height - 30.0).abs() < 1e-9);
// Layer 0 frozen in the viewport: nothing of the model shows.
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default());
assert!(list.prims.iter().all(|p| p.handle != cadcraft_render::VIEWPORT_CONTENT));
for bad_p in [
json!({"handle": h, "scale": "0:0"}),
json!({"handle": h, "scale": -1}),
json!({"handle": h, "viewHeight": 0}),
json!({"handle": "FFFFFF"}),
] {
assert!(s.execute("viewport.set", &bad_p).is_err(), "{bad_p}");
}
}
#[test]
fn pagesetup_paper_table() {
let mut s = Session::new();
let r = s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3", "landscape": true, "margins": [5, 5, 5, 5]})).unwrap();
assert_eq!(r["page"]["widthMm"], 297.0);
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap();
// Imperial drawing: paper units are inches.
assert!((sheet.size.x - 420.0 / 25.4).abs() < 1e-9 && (sheet.size.y - 297.0 / 25.4).abs() < 1e-9);
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "Z12"})).is_err());
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "margins": [1, 2]})).is_err());
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "width": 1e308, "height": 5})).is_err());
assert!(s.execute("pagesetup", &json!({"paper": "A4"})).is_err(), "model tab needs a layout");
let report = s.execute("pagesetup", &Value::Null).unwrap();
assert!(report["papers"].as_array().unwrap().len() >= 10);
s.execute("pagesetup", &json!({"layout": "Layout2", "width": 500, "height": 300})).unwrap();
let p = &s.doc().unwrap().layout("Layout2").unwrap().page;
assert_eq!((p.width_mm, p.height_mm), (300.0, 500.0));
}
fn fake_plot(d: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
let _ = d;
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
}
#[test]
fn plot_goes_through_the_hook() {
super::super::file::set_io(super::super::file::IoHooks {
read: |_, _| Err("no reader in tests".into()),
write: |_, _| Err("no writer in tests".into()),
plot: Some(fake_plot),
});
let mut s = session_with_model();
if io().and_then(|h| h.plot).is_none() {
return; // another test installed hooks without a plotter first
}
let r = s.execute("plot", &json!({"layout": "Layout1", "paper": "A4"})).unwrap();
let data = super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap();
let text = String::from_utf8(data).unwrap();
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
let r = s.execute("exportpdf", &json!({})).unwrap();
assert_eq!(r["layout"], "Model");
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
let ps = path.to_string_lossy().to_string();
let r = s.execute("plot", &json!({"path": ps})).unwrap();
assert!(r["bytes"].as_u64().unwrap() > 0);
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"));
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn hostile_layout_params() {
let mut s = session_with_model();
let long = "x".repeat(300);
for (c, p) in [
("layout.new", json!({"name": ""})),
("layout.new", json!({"name": long})),
("layout.new", json!({"name": "a\u{0}b"})),
("layout.new", json!({"name": "Layout1"})),
("mview", json!({"layout": "Layout1", "p1": [1e308, -1e308], "p2": [-1e308, 1e308]})),
("mview", json!({"layout": "Layout1", "p1": [1, 1], "p2": [1, 1]})),
("mview", json!({"layout": "Layout1", "count": 99})),
("mview", json!({"layout": "Layout1", "count": -1})),
("layout.rename", json!({"from": "Layout1"})),
("layout.copy", json!({"from": "nope"})),
("viewport.set", json!({"handles": ["zz"]})),
] {
assert!(s.execute(c, &p).is_err(), "{c} {p}");
}
}
}
+11
View File
@@ -4,16 +4,22 @@
mod annotate;
mod blocks;
mod draw;
mod draw2;
mod edit;
pub mod file;
mod gripcmds;
mod hatch;
mod inquiry;
mod layer;
mod layout;
mod modify;
mod modify2;
mod props;
mod settings;
mod utility;
mod view;
pub mod curves;
pub mod helpers;
pub mod machines;
@@ -142,14 +148,19 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(edit::specs());
v.extend(view::specs());
v.extend(draw::specs());
v.extend(draw2::specs());
v.extend(annotate::specs());
v.extend(hatch::specs());
v.extend(blocks::specs());
v.extend(modify::specs());
v.extend(modify2::specs());
v.extend(gripcmds::specs());
v.extend(layer::specs());
v.extend(layout::specs());
v.extend(props::specs());
v.extend(inquiry::specs());
v.extend(settings::specs());
v.extend(utility::specs());
v
})
}
+28 -5
View File
@@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("fillet", "Fillet", run_fillet)
.menu(&["Modify", "Fillet"])
.alias(&["f"])
.params("{h1, p1, h2, p2, radius?}")
.params("{h1, p1, h2, p2, radius?} (lines, arcs, circles) | {handle, polyline: true, radius?}")
.interactive(|s| Ok(Box::new(FilletM::new(s, false)))),
CommandSpec::new("chamfer", "Chamfer", run_chamfer)
.menu(&["Modify", "Chamfer"])
@@ -549,7 +549,7 @@ pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handl
keep.into_iter().collect()
}
EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?,
_ => return Err(EngineError::Other("Cannot trim that object.".into())),
k => super::modify2::trim_other(k, &cut, pick)?,
};
let common = e.common.clone();
let space = s.space();
@@ -711,7 +711,7 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Han
}
lwpoly(vs, false)
}
_ => return Err(EngineError::Other("Cannot extend that object.".into())),
k => super::modify2::extend_other(k, &cut, pick)?,
};
s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?;
Ok(())
@@ -760,7 +760,10 @@ pub(crate) fn fillet_lines(
let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else {
return Err(EngineError::Other("Fillet/chamfer currently works on lines.".into()));
if chamfer.is_none() {
return super::modify2::fillet_curves(s, h1, p1, h2, p2, radius);
}
return Err(EngineError::Other("Chamfer currently works on lines.".into()));
};
let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?;
// Keep the far end on the picked side of the corner.
@@ -820,6 +823,12 @@ fn h_param(p: &Value, k: &str) -> Option<Handle> {
}
fn run_fillet(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "polyline", false) {
let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("fillet", "`handle` (polyline) is required"))?;
let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0));
let n = super::modify2::fillet_polyline(s, h, r)?;
return Ok(json!({ "filleted": n }));
}
let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?;
let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?;
let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0));
@@ -1752,11 +1761,12 @@ struct FilletM {
chamfer: bool,
first: Option<(Handle, Vec2)>,
asking: bool,
polyline: bool,
}
impl FilletM {
fn new(_s: &Session, chamfer: bool) -> Self {
FilletM { chamfer, first: None, asking: false }
FilletM { chamfer, first: None, asking: false, polyline: false }
}
}
@@ -1779,6 +1789,9 @@ impl Interactive for FilletM {
if self.asking {
return Prompt::new(if self.chamfer { "Specify first chamfer distance" } else { "Specify fillet radius" }, Accept::NUMBER);
}
if self.polyline {
return Prompt::new("Select 2D polyline", Accept::POINT);
}
match (self.first, self.chamfer) {
(None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]),
(None, true) => {
@@ -1808,6 +1821,10 @@ impl Interactive for FilletM {
self.asking = true;
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Polyline" && !self.chamfer => {
self.polyline = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
@@ -1815,6 +1832,12 @@ impl Interactive for FilletM {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
if self.polyline {
let r = s.doc()?.header.f64("FILLETRAD", 0.0);
let n = super::modify2::fillet_polyline(s, h, r)?;
s.echo(format!("{n} lines were filleted"));
return Ok(Step::Done);
}
match self.first {
None => {
self.first = Some((h, p));
File diff suppressed because it is too large Load Diff
+607
View File
@@ -0,0 +1,607 @@
use cadcraft_doc::{EntityKind, Handle, Space};
use cadcraft_geom::{Arc, Polyline, Vec2};
use serde_json::{Value, json};
use crate::Session;
fn last(s: &Session) -> EntityKind {
s.doc().unwrap().model.last().unwrap().kind.clone()
}
fn kind(s: &Session, h: &str) -> EntityKind {
s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone()
}
fn h(r: &Value) -> String {
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
}
fn near(a: Vec2, x: f64, y: f64) -> bool {
a.near(Vec2::new(x, y), 1e-6)
}
fn pline(s: &mut Session, pts: Value, closed: bool) -> String {
h(&s.execute("pline", &json!({"vertices": pts, "closed": closed})).unwrap())
}
fn poly(k: &EntityKind) -> cadcraft_doc::LwPolyline {
match k {
EntityKind::LwPolyline(p) => p.clone(),
k => panic!("not a polyline: {k:?}"),
}
}
fn arc_of(k: &EntityKind) -> Arc {
match k {
EntityKind::Arc(a) => Arc::new(a.center.xy(), a.radius, a.start, a.end),
k => panic!("not an arc: {k:?}"),
}
}
// ---------------- PEDIT ----------------
#[test]
fn pedit_json_options() {
let mut s = Session::new();
let p = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 10]]), false);
s.execute("pedit", &json!({"handle": p, "option": "close"})).unwrap();
assert!(poly(&kind(&s, &p)).closed);
s.execute("pedit", &json!({"handle": p, "option": "open"})).unwrap();
assert!(!poly(&kind(&s, &p)).closed);
s.execute("pedit", &json!({"handle": p, "option": "width", "width": 0.5})).unwrap();
assert_eq!(poly(&kind(&s, &p)).const_width, 0.5);
s.execute("pedit", &json!({"handle": p, "option": "fit"})).unwrap();
let f = poly(&kind(&s, &p));
assert_eq!(f.vertices.len(), 7);
assert!(f.vertices.iter().take(6).all(|v| v.bulge.abs() > 1e-6), "fit curve is all arcs");
// Tangent continuity at an original vertex.
let segs = Polyline { vertices: f.vertices.clone(), closed: false }.segments();
assert!(segs[1].tangent(1.0).near(segs[2].tangent(0.0), 1e-6));
assert!(near(f.vertices[2].p, 10.0, 0.0), "original vertices are kept");
s.execute("pedit", &json!({"handle": p, "option": "decurve"})).unwrap();
assert!(poly(&kind(&s, &p)).vertices.iter().all(|v| v.bulge == 0.0));
let q = pline(&mut s, json!([[0, 20], [10, 20], [10, 30], [0, 30]]), false);
s.execute("pedit", &json!({"handle": q, "option": "spline"})).unwrap();
let sp = poly(&kind(&s, &q));
assert_eq!(sp.vertices.len(), 25);
assert!(near(sp.vertices[0].p, 0.0, 20.0) && near(sp.vertices[24].p, 0.0, 30.0));
s.execute("pedit", &json!({"handle": q, "option": "reverse"})).unwrap();
assert!(near(poly(&kind(&s, &q)).vertices[0].p, 0.0, 30.0));
s.execute("pedit", &json!({"handle": q, "option": "ltypegen"})).unwrap();
assert!(poly(&kind(&s, &q)).plinegen);
// Vertex editing.
let r = pline(&mut s, json!([[0, 50], [5, 50], [10, 50]]), false);
s.execute("pedit", &json!({"handle": r, "option": "vertex", "action": "move", "index": 1, "to": [5, 55]})).unwrap();
assert!(near(poly(&kind(&s, &r)).vertices[1].p, 5.0, 55.0));
s.execute("pedit", &json!({"handle": r, "option": "vertex", "action": "insert", "index": 0, "to": [2, 52]})).unwrap();
assert_eq!(poly(&kind(&s, &r)).vertices.len(), 4);
s.execute("pedit", &json!({"handle": r, "option": "vertex", "action": "width", "index": 0, "startWidth": 1, "endWidth": 0})).unwrap();
assert_eq!(poly(&kind(&s, &r)).vertices[0].start_width, 1.0);
assert!(s.execute("pedit", &json!({"handle": r, "option": "vertex", "index": 99, "to": [0, 0]})).is_err());
assert!(s.execute("pedit", &json!({"handle": r, "option": "bogus"})).is_err());
// A line is converted, then joined with a touching arc and polyline.
let l = h(&s.execute("line", &json!({"points": [[0, 100], [10, 100]]})).unwrap());
let a = h(&s.execute("arc", &json!({"center": [10, 105], "radius": 5, "start": -90, "end": 90})).unwrap());
let far = h(&s.execute("line", &json!({"points": [[50, 50], [60, 60]]})).unwrap());
let tail = pline(&mut s, json!([[10, 110], [0, 110]]), false);
let r = s.execute("pedit", &json!({"handle": l, "option": "join", "handles2": [a, tail, far]})).unwrap();
assert_eq!(r["joined"], 2);
let j = poly(&kind(&s, &l));
assert_eq!(j.vertices.len(), 4);
assert!(j.vertices[1].bulge > 0.9);
assert!(s.doc().unwrap().entity(Handle::parse_hex(&far).unwrap()).is_some(), "unconnected objects stay");
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap());
assert!(s.execute("pedit", &json!({"handle": c, "option": "close"})).is_err());
}
#[test]
fn pedit_command_line() {
let mut s = Session::new();
pline(&mut s, json!([[0, 0], [10, 0], [10, 10]]), false);
s.cmdline("pedit 5,0 c").unwrap();
assert!(poly(&last(&s)).closed);
s.cmdline("w 0.25").unwrap();
s.cmdline("f").unwrap();
assert!(poly(&last(&s)).vertices.len() > 3);
s.cmdline("d").unwrap();
assert_eq!(poly(&last(&s)).vertices.len(), 3, "decurve restores the frame");
s.cmdline("u").unwrap();
assert!(poly(&last(&s)).vertices.len() > 3, "undo the decurve");
s.cmdline("u u").unwrap();
s.cmdline("o").unwrap();
assert!(!poly(&last(&s)).closed);
s.cmdline("e n m 10,-5 x").unwrap();
assert!(near(poly(&last(&s)).vertices[1].p, 10.0, -5.0));
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(s.state().unwrap().undo.len(), 2, "one undo step for the PEDIT session");
// Line conversion and join.
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
s.execute("line", &json!({"points": [[5, 0], [5, 5]]})).unwrap();
s.cmdline("pedit 2,0 y j 5,3").unwrap();
s.cmdline("").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(s.doc().unwrap().model.len(), 1);
assert_eq!(poly(&last(&s)).vertices.len(), 3);
}
// ---------------- SPLINEDIT ----------------
#[test]
fn splinedit_json_and_command_line() {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0], [6, 2]]})).unwrap());
s.execute("splinedit", &json!({"handle": sp, "option": "close"})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.closed && x.fit.len() == 5));
s.execute("splinedit", &json!({"handle": sp, "option": "open"})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if !x.closed && x.fit.len() == 4));
s.execute("splinedit", &json!({"handle": sp, "option": "reverse"})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if near(x.fit[0], 6.0, 2.0)));
s.execute("splinedit", &json!({"handle": sp, "option": "move", "index": 0, "to": [7, 3]})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if near(x.fit[0], 7.0, 3.0)));
s.execute("splinedit", &json!({"handle": sp, "option": "refit", "fit": [[0, 0], [1, 1], [2, 0]]})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.fit.len() == 3));
s.execute("splinedit", &json!({"handle": sp, "option": "purge"})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.fit.is_empty()));
assert!(s.execute("splinedit", &json!({"handle": sp, "option": "refit"})).is_err());
s.execute("splinedit", &json!({"handle": sp, "option": "move", "index": 1, "to": [1, 5]})).unwrap();
s.execute("splinedit", &json!({"handle": sp, "option": "polyline", "precision": 4})).unwrap();
assert!(matches!(kind(&s, &sp), EntityKind::LwPolyline(p) if p.vertices.len() > 4));
assert!(s.execute("splinedit", &json!({"handle": sp, "option": "close"})).is_err());
let mut s = Session::new();
s.execute("spline", &json!({"control": [[0, 0], [2, 3], [4, 0], [6, 3]]})).unwrap();
s.cmdline("splinedit 0,0 c").unwrap();
assert!(matches!(last(&s), EntityKind::Spline(x) if x.closed));
s.cmdline("o r u").unwrap();
assert!(matches!(last(&s), EntityKind::Spline(x) if !x.closed && near(x.control[0], 0.0, 0.0)));
s.cmdline("f p p").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::LwPolyline(_)));
}
// ---------------- LENGTHEN ----------------
#[test]
fn lengthen_json() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
assert_eq!(s.execute("lengthen", &json!({"handle": l})).unwrap()["length"], 10.0);
s.execute("lengthen", &json!({"handle": l, "delta": 2})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 12.0).abs() < 1e-9));
s.execute("lengthen", &json!({"handle": l, "delta": -2, "pick": [0, 0]})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.a.x - 2.0).abs() < 1e-9));
s.execute("lengthen", &json!({"handle": l, "percent": 50})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 7.0).abs() < 1e-9));
s.execute("lengthen", &json!({"handle": l, "total": 3})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 5.0).abs() < 1e-9));
s.execute("lengthen", &json!({"handle": l, "to": [9, 4]})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 9.0).abs() < 1e-9 && x.b.y == 0.0));
assert!(s.execute("lengthen", &json!({"handle": l, "delta": -100})).is_err());
assert!(s.execute("lengthen", &json!({"handle": l, "deltaAngle": 10})).is_err());
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 90})).unwrap());
s.execute("lengthen", &json!({"handle": a, "deltaAngle": 90})).unwrap();
assert!((arc_of(&kind(&s, &a)).sweep() - std::f64::consts::PI).abs() < 1e-9);
s.execute("lengthen", &json!({"handle": a, "totalAngle": 45, "pick": [1, 0]})).unwrap();
let g = arc_of(&kind(&s, &a));
assert!((g.sweep() - std::f64::consts::FRAC_PI_4).abs() < 1e-9 && near(g.end_point(), -1.0, 0.0));
s.execute("lengthen", &json!({"handle": a, "total": std::f64::consts::FRAC_PI_2})).unwrap();
assert!((arc_of(&kind(&s, &a)).sweep() - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
assert!(s.execute("lengthen", &json!({"handle": a, "percent": 1000})).is_err());
let p = pline(&mut s, json!([[0, 10], [5, 10], [5, 15]]), false);
s.execute("lengthen", &json!({"handle": p, "delta": 5})).unwrap();
assert!(near(poly(&kind(&s, &p)).vertices[2].p, 5.0, 20.0));
s.execute("lengthen", &json!({"handle": p, "total": 20, "pick": [0, 10]})).unwrap();
assert!(near(poly(&kind(&s, &p)).vertices[0].p, -5.0, 10.0));
}
#[test]
fn lengthen_command_line() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.cmdline("lengthen 5,0").unwrap();
assert!(s.log.iter().any(|l| l.contains("Current length: 10.0000")));
s.cmdline("de 2 9,0 9,0 u").unwrap();
assert!(matches!(last(&s), EntityKind::Line(x) if (x.b.x - 12.0).abs() < 1e-9));
s.cmdline("").unwrap();
assert!(s.running.is_none());
s.cmdline("lengthen t 4 1,0").unwrap();
s.cmdline("").unwrap();
assert!(matches!(last(&s), EntityKind::Line(x) if (x.a.x - 8.0).abs() < 1e-9));
s.cmdline("lengthen p 50 11,0").unwrap();
s.cmdline("").unwrap();
assert!(matches!(last(&s), EntityKind::Line(x) if (x.b.x - 10.0).abs() < 1e-9));
s.cmdline("lengthen dy 9.5,0 20,3").unwrap();
s.cmdline("").unwrap();
assert!(matches!(last(&s), EntityKind::Line(x) if (x.b.x - 20.0).abs() < 1e-9));
s.execute("arc", &json!({"center": [50, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
s.cmdline("lengthen de a 90 50,1").unwrap();
s.cmdline("").unwrap();
assert!((arc_of(&last(&s)).sweep() - std::f64::consts::PI).abs() < 1e-9);
}
// ---------------- ALIGN / BLEND / REVERSE ----------------
#[test]
fn align_json_and_command_line() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap());
s.execute("align", &json!({"handles": [l], "s1": [0, 0], "d1": [5, 5], "s2": [2, 0], "d2": [5, 9], "scale": true})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.a.xy(), 5.0, 5.0) && near(x.b.xy(), 5.0, 9.0)));
s.execute("align", &json!({"handles": [l], "s1": [5, 5], "d1": [0, 0]})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.a.xy(), 0.0, 0.0) && near(x.b.xy(), 0.0, 4.0)));
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
s.cmdline("align 1,0").unwrap();
s.cmdline("").unwrap();
s.cmdline("0,0 10,10 2,0 10,11").unwrap();
s.cmdline("").unwrap();
s.cmdline("n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(matches!(last(&s), EntityKind::Line(x) if near(x.a.xy(), 10.0, 10.0) && near(x.b.xy(), 10.0, 12.0)));
// One pair only: a move.
s.cmdline("align 10,11").unwrap();
s.cmdline("").unwrap();
s.cmdline("10,10 0,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::Line(x) if near(x.a.xy(), 0.0, 0.0)));
}
#[test]
fn blend_json_and_command_line() {
let mut s = Session::new();
let a = h(&s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap());
let b = h(&s.execute("line", &json!({"points": [[10, 3], [15, 3]]})).unwrap());
let r = s.execute("blend", &json!({"h1": a, "p1": [4, 0], "h2": b, "p2": [11, 3]})).unwrap();
match kind(&s, &h(&r)) {
EntityKind::Spline(sp) => {
assert_eq!(sp.degree, 3);
assert!(near(sp.control[0], 5.0, 0.0) && near(sp.control[3], 10.0, 3.0));
assert!(sp.control[1].y.abs() < 1e-9 && sp.control[1].x > 5.0, "tangent to the first line");
assert!((sp.control[2].y - 3.0).abs() < 1e-9 && sp.control[2].x < 10.0, "tangent to the second line");
}
k => panic!("{k:?}"),
}
let r = s.execute("blend", &json!({"h1": a, "p1": [1, 0], "h2": b, "p2": [14, 3], "continuity": "smooth"})).unwrap();
assert!(matches!(kind(&s, &h(&r)), EntityKind::Spline(sp) if sp.degree == 5 && near(sp.control[0], 0.0, 0.0)));
let c = h(&s.execute("circle", &json!({"center": [30, 0], "radius": 1})).unwrap());
assert!(s.execute("blend", &json!({"h1": a, "p1": [1, 0], "h2": c, "p2": [31, 0]})).is_err());
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
s.execute("arc", &json!({"center": [10, 0], "radius": 2, "start": 90, "end": 180})).unwrap();
s.cmdline("blend con s 4,0 8,0").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.degree == 5 && near(*sp.control.last().unwrap(), 8.0, 0.0)));
}
#[test]
fn reverse_json_and_command_line() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap());
let p = pline(&mut s, json!([{"p": [0, 5], "bulge": 1}, [2, 5], [4, 6]]), false);
let sp = h(&s.execute("spline", &json!({"control": [[0, 10], [1, 12], [3, 12], [4, 10]]})).unwrap());
let p3 = h(&s.execute("3dpoly", &json!({"points": [[0, 20, 0], [1, 20, 1]]})).unwrap());
let c = h(&s.execute("circle", &json!({"center": [0, 30], "radius": 1})).unwrap());
let before = Polyline { vertices: poly(&kind(&s, &p)).vertices, closed: false }.tessellate(1e-3);
let r = s.execute("reverse", &json!({"handles": [l, p, sp, p3, c]})).unwrap();
assert_eq!(r["reversed"], 4);
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.a.xy(), 5.0, 0.0)));
let after = Polyline { vertices: poly(&kind(&s, &p)).vertices, closed: false }.tessellate(1e-3);
assert!(after.first().unwrap().near(*before.last().unwrap(), 1e-9));
let mid_b = before[before.len() / 2];
assert!(after.iter().any(|q| q.near(mid_b, 1e-2)), "same shape");
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if near(x.eval(x.domain().0), 4.0, 10.0)));
assert!(matches!(kind(&s, &p3), EntityKind::Polyline3d(x) if x.points[0].z == 1.0));
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
s.cmdline("reverse 2,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::Line(x) if near(x.a.xy(), 5.0, 0.0)));
}
// ---------------- NCOPY / CHSPACE / FLATTEN ----------------
fn make_block(s: &mut Session) -> String {
let a = h(&s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap());
let c = h(&s.execute("circle", &json!({"center": [3, 0], "radius": 0.5})).unwrap());
s.execute("block", &json!({"name": "PART", "base": [0, 0], "handles": [a, c], "keep": "delete"})).unwrap();
h(&s.execute("insert", &json!({"name": "PART", "at": [10, 10], "rotation": 90})).unwrap())
}
#[test]
fn ncopy_json_and_command_line() {
let mut s = Session::new();
let ins = make_block(&mut s);
let r = s.execute("ncopy", &json!({"handle": ins, "pick": [10, 13]})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 1);
assert!(matches!(last(&s), EntityKind::Circle(c) if near(c.center.xy(), 10.0, 13.0)));
let r = s.execute("ncopy", &json!({"handle": ins, "delta": [5, 0]})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
assert!(matches!(last(&s), EntityKind::Circle(c) if near(c.center.xy(), 15.0, 13.0)));
let line = h(&s.execute("line", &json!({"points": [[50, 50], [51, 50]]})).unwrap());
assert!(s.execute("ncopy", &json!({"handle": line})).is_err());
let mut s = Session::new();
make_block(&mut s);
s.cmdline("ncopy 10,10.5").unwrap();
s.cmdline("").unwrap();
s.cmdline("0,0 20,0").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(matches!(last(&s), EntityKind::Line(l) if near(l.a.xy(), 30.0, 10.0) && near(l.b.xy(), 30.0, 11.0)));
}
#[test]
fn chspace_json_and_command_line() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
// A viewport on Layout1 showing model (0,0) at paper (5,5) at scale 1:2.
let vp = cadcraft_doc::Viewport {
center: cadcraft_geom::Vec3::new(5.0, 5.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::ZERO,
view_height: 12.0,
id: 2,
locked: false,
frozen_layers: Vec::new(),
};
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();
let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();
assert_eq!(r["moved"], 1);
assert_eq!(r["to"], "Layout1");
let hh = Handle::parse_hex(&l).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.space_of(hh), Some(Space::Paper("Layout1".into())));
assert!(matches!(&d.entity(hh).unwrap().kind, EntityKind::Line(x) if near(x.a.xy(), 5.0, 5.0) && near(x.b.xy(), 10.0, 5.0)));
// Back to model space from the layout.
s.state_mut().unwrap().space = Space::Paper("Layout1".into());
s.execute("chspace", &json!({"handles": [l]})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.space_of(hh), Some(Space::Model));
assert!(matches!(&d.entity(hh).unwrap().kind, EntityKind::Line(x) if near(x.b.xy(), 10.0, 0.0)));
s.state_mut().unwrap().space = Space::Model;
assert!(s.execute("chspace", &json!({"handles": [l], "to": "Nope"})).is_err());
s.cmdline("chspace 5,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(s.doc().unwrap().space_of(hh), Some(Space::Paper("Layout1".into())));
assert!(s.log.iter().any(|l| l.contains("changed from MODEL space to PAPER space")));
}
#[test]
fn flatten_json_and_command_line() {
let mut s = Session::new();
let p3 = h(&s.execute("3dpoly", &json!({"points": [[0, 0, 5], [1, 0, 6], [1, 1, 7]]})).unwrap());
let l = h(&s.execute("line", &json!({"points": [[0, 5], [3, 5]]})).unwrap());
s.doc_mut()
.unwrap()
.modify_entity(Handle::parse_hex(&l).unwrap(), |e| {
if let EntityKind::Line(x) = &mut e.kind {
x.a.z = 4.0;
}
e.common.thickness = 2.0;
})
.unwrap();
let r = s.execute("flatten", &json!({"handles": [p3, l]})).unwrap();
assert_eq!(r["flattened"], 2);
assert!(matches!(kind(&s, &p3), EntityKind::LwPolyline(p) if p.vertices.len() == 3));
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if x.a.z == 0.0));
assert_eq!(s.doc().unwrap().entity(Handle::parse_hex(&l).unwrap()).unwrap().common.thickness, 0.0);
let mut s = Session::new();
s.execute("3dpoly", &json!({"points": [[0, 0, 5], [4, 0, 6]]})).unwrap();
s.cmdline("flatten 2,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::LwPolyline(_)));
}
// ---------------- FILLET extensions ----------------
#[test]
fn fillet_line_arc_and_arcs() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[-10, 0], [10, 0]]})).unwrap());
let a = h(&s.execute("arc", &json!({"center": [0, 4], "radius": 3, "start": 180, "end": 360})).unwrap());
// Arc bottom is at y=1; a fillet of radius 1 between the line (below) and the arc.
let r = s.execute("fillet", &json!({"h1": l, "p1": [8, 0], "h2": a, "p2": [3, 4], "radius": 1})).unwrap();
let fa = arc_of(&kind(&s, r["arc"].as_str().unwrap()));
assert!((fa.radius - 1.0).abs() < 1e-9 && (fa.center.y - 1.0).abs() < 1e-9 && fa.center.x > 0.0, "{fa:?}");
// Tangent points: the line now ends under the fillet centre; the arc ends where it touches.
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.a.x - fa.center.x).abs() < 1e-9 && x.b.x == 10.0));
let ga = arc_of(&kind(&s, &a));
assert!((ga.start_point().dist(fa.center) - 1.0).abs() < 1e-6 || (ga.end_point().dist(fa.center) - 1.0).abs() < 1e-6);
// Endpoints connect.
assert!(fa.start_point().near(Vec2::new(fa.center.x, 0.0), 1e-6) || fa.end_point().near(Vec2::new(fa.center.x, 0.0), 1e-6));
// Two arcs.
let mut s = Session::new();
let a1 = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 90})).unwrap());
let a2 = h(&s.execute("arc", &json!({"center": [5, 0], "radius": 2, "start": 90, "end": 180})).unwrap());
let r = s.execute("fillet", &json!({"h1": a1, "p1": [1.4, 1.4], "h2": a2, "p2": [3.6, 1.4], "radius": 1})).unwrap();
let fa = arc_of(&kind(&s, r["arc"].as_str().unwrap()));
assert!((fa.center.x - 2.5).abs() < 1e-9 && fa.center.y > 0.0);
let g1 = arc_of(&kind(&s, &a1));
let g2 = arc_of(&kind(&s, &a2));
assert!(
fa.start_point().near(g1.start_point(), 1e-6) || fa.start_point().near(g1.end_point(), 1e-6) || fa.end_point().near(g1.end_point(), 1e-6)
);
assert!(
fa.start_point().near(g2.start_point(), 1e-6) || fa.end_point().near(g2.start_point(), 1e-6) || fa.end_point().near(g2.end_point(), 1e-6)
);
// Zero radius: sharp corner between a line and an arc.
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[-10, 0], [-5, 0]]})).unwrap());
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 90, "end": 150})).unwrap());
s.execute("fillet", &json!({"h1": l, "p1": [-8, 0], "h2": a, "p2": [0, 3], "radius": 0})).unwrap();
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.b.xy(), -3.0, 0.0)));
assert!(near(arc_of(&kind(&s, &a)).end_point(), -3.0, 0.0));
// Unsupported pairs report an error.
let t = h(&s.execute("text", &json!({"at": [0, 0], "text": "x"})).unwrap());
assert!(s.execute("fillet", &json!({"h1": l, "p1": [-8, 0], "h2": t, "p2": [0, 0], "radius": 1})).is_err());
}
#[test]
fn fillet_polyline_json_and_command_line() {
let mut s = Session::new();
let p = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 10]]), true);
let r = s.execute("fillet", &json!({"handle": p, "polyline": true, "radius": 1})).unwrap();
assert_eq!(r["filleted"], 4);
let pl = poly(&kind(&s, &p));
assert_eq!(pl.vertices.len(), 8);
let area = Polyline { vertices: pl.vertices.clone(), closed: true }.area();
assert!((area - (100.0 - 4.0 + std::f64::consts::PI)).abs() < 1e-3, "{area}");
// Too-large radius: corners that do not fit are skipped.
let q = pline(&mut s, json!([[0, 20], [2, 20], [2, 22]]), false);
assert_eq!(s.execute("fillet", &json!({"handle": q, "polyline": true, "radius": 5})).unwrap()["filleted"], 0);
let mut s = Session::new();
pline(&mut s, json!([[0, 0], [10, 0], [10, 10]]), false);
s.cmdline("fillet r 2").unwrap();
s.cmdline("p 5,0").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(poly(&last(&s)).vertices.len(), 4);
assert!(s.log.iter().any(|l| l.contains("1 lines were filleted")));
}
#[test]
fn fillet_command_line_line_and_arc() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[-10, 0], [10, 0]]})).unwrap();
s.execute("arc", &json!({"center": [0, 4], "radius": 3, "start": 180, "end": 360})).unwrap();
s.cmdline("fillet r 1").unwrap();
s.cmdline("8,0 3,4").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::Arc(a) if (a.radius - 1.0).abs() < 1e-9));
}
// ---------------- TRIM / EXTEND for ellipses and splines ----------------
#[test]
fn trim_and_extend_ellipse_and_spline() {
let mut s = Session::new();
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap());
s.execute("line", &json!({"points": [[0, -5], [0, 5]]})).unwrap();
let r = s.execute("trim", &json!({"handle": e, "pick": [4, 0]})).unwrap();
let nh = r["handles"][0].as_str().unwrap().to_string();
match kind(&s, &nh) {
EntityKind::Ellipse(x) => {
let ge = cadcraft_geom::Ellipse { center: x.center.xy(), major: x.major.xy(), ratio: x.ratio, start: x.start, end: x.end };
assert!((ge.sweep() - std::f64::consts::PI).abs() < 1e-6);
assert!(ge.at_param(ge.start + ge.sweep() / 2.0).x < 0.0, "the right half was removed");
}
k => panic!("{k:?}"),
}
// Extend the elliptical arc back to a new edge.
s.execute("line", &json!({"points": [[3, -5], [3, 5]]})).unwrap();
let mut s2 = Session::new();
let ea = h(&s2.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 90, "end": 180})).unwrap());
s2.execute("line", &json!({"points": [[2, -5], [2, 5]]})).unwrap();
s2.execute("extend", &json!({"handle": ea, "pick": [0, 2]})).unwrap();
match kind(&s2, &ea) {
EntityKind::Ellipse(x) => {
let ge = cadcraft_geom::Ellipse { center: x.center.xy(), major: x.major.xy(), ratio: x.ratio, start: x.start, end: x.end };
assert!((ge.at_param(ge.start).x - 2.0).abs() < 1e-6, "{:?}", ge.at_param(ge.start));
}
k => panic!("{k:?}"),
}
// Splines.
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [3, 3], [6, -3], [9, 0]]})).unwrap());
s.execute("line", &json!({"points": [[4.5, -5], [4.5, 5]]})).unwrap();
let r = s.execute("trim", &json!({"handle": sp, "pick": [8.9, 0.0]})).unwrap();
let nh = r["handles"][0].as_str().unwrap().to_string();
match kind(&s, &nh) {
EntityKind::Spline(x) => {
assert!(near(x.eval(x.domain().0), 0.0, 0.0));
assert!((x.eval(x.domain().1).x - 4.5).abs() < 1e-6);
}
k => panic!("{k:?}"),
}
assert!(s.execute("extend", &json!({"handle": nh, "pick": [4.5, 0]})).is_err());
}
#[test]
fn new_modify_commands_survive_garbage_input() {
let ids = ["pedit", "splinedit", "lengthen", "align", "blend", "reverse", "ncopy", "chspace", "flatten", "fillet", "trim", "extend"];
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let sp = h(&s.execute("spline", &json!({"control": [[0, 1], [1, 2], [2, 1]]})).unwrap());
let e = h(&s.execute("ellipse", &json!({"center": [5, 5], "major": [1, 0], "ratio": 0.5})).unwrap());
for id in ids {
for p in [
json!({"handle": l, "option": "vertex", "action": "insert", "index": u64::MAX, "to": [1e308, 1e308]}),
json!({"handle": sp, "option": "move", "index": 1e10, "to": [0, 0]}),
json!({"handle": l, "delta": 1e308, "percent": -1, "to": [1e308, -1e308]}),
json!({"handles": [l, sp, e], "s1": [0, 0], "d1": [0, 0], "s2": [0, 0], "d2": [1e308, 0], "scale": true}),
json!({"h1": l, "p1": [0, 0], "h2": l, "p2": [0, 0], "radius": 1e308}),
json!({"handle": e, "pick": [1e308, 0], "polyline": true}),
json!({"handle": sp, "pick": [0, 0]}),
json!({"handles": ["zz"], "to": ""}),
] {
let _ = s.execute(id, &p);
}
s.start(id).unwrap();
for t in ["5,0", "", "x", "1e999", "c", "e", "n", "m", "0,0", "5,5", "u", "x", "", ""] {
if s.running.is_none() {
break;
}
let _ = s.cmdline(t);
}
let _ = s.preview(Vec2::new(-1e300, 1e300));
s.cancel();
}
}
#[test]
fn arraypath_json_and_command_line() {
let mut s = Session::new();
let dot = h(&s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap());
let path = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let r = s.execute("arraypath", &json!({"handles": [dot], "path": path, "count": 6})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 5);
assert!(matches!(last(&s), EntityKind::Circle(c) if near(c.center.xy(), 10.0, 1.0)));
let r = s.execute("arraypath", &json!({"handles": [dot], "path": path, "spacing": 4})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
// Aligned along an arc: the copy turns with the tangent.
let arc = h(&s.execute("arc", &json!({"center": [0, 20], "radius": 5, "start": 0, "end": 180})).unwrap());
let tick = h(&s.execute("line", &json!({"points": [[5, 20], [6, 20]]})).unwrap());
s.execute("arraypath", &json!({"handles": [tick], "path": arc, "count": 2})).unwrap();
assert!(matches!(last(&s), EntityKind::Line(l) if near(l.a.xy(), -5.0, 20.0) && near(l.b.xy(), -6.0, 20.0)));
assert!(s.execute("arraypath", &json!({"handles": [dot], "path": dot})).is_err());
let mut s = Session::new();
s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.cmdline("arraypath 0.25,1").unwrap();
s.cmdline("").unwrap();
s.cmdline("5,0 3").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(s.doc().unwrap().model.len(), 4);
}
#[test]
fn textedit_json_and_command_line() {
let mut s = Session::new();
let t = h(&s.execute("text", &json!({"at": [0, 0], "text": "old"})).unwrap());
s.execute("textedit", &json!({"handle": t, "text": "new"})).unwrap();
assert!(matches!(kind(&s, &t), EntityKind::Text(x) if x.value == "new"));
let m = h(&s.execute("mtext", &json!({"at": [0, 5], "text": "a"})).unwrap());
s.execute("textedit", &json!({"handle": m, "text": "x\ny"})).unwrap();
assert!(matches!(kind(&s, &m), EntityKind::MText(x) if x.contents == "x\\Py"));
let l = h(&s.execute("line", &json!({"points": [[0, 10], [1, 10]]})).unwrap());
assert!(s.execute("textedit", &json!({"handle": l, "text": "x"})).is_err());
s.cmdline("textedit 0,0").unwrap();
s.cmdline("hello world").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(matches!(kind(&s, &t), EntityKind::Text(x) if x.value == "hello world"));
}
+449
View File
@@ -0,0 +1,449 @@
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("cal", "QuickCalc", run_cal)
.menu(&["Tools", "Inquiry", "Quick"])
.alias(&["quickcalc", "qc"])
.params("{expr: \"(3+4)*2^2\", vars?: {x: 1}}")
.enabled(always)
.noundo()
.transparent(),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
.params("{handles?}")
.noundo(),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom).params("{mode: distance|radius|angle|area, ...}").noundo(),
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
]
}
// ---------------- CAL ----------------
struct Parser<'a> {
s: &'a [u8],
i: usize,
vars: &'a serde_json::Map<String, Value>,
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 expr(&mut self) -> std::result::Result<f64, String> {
self.depth += 1;
if self.depth > 200 {
return Err("expression too deeply nested".into());
}
let mut v = self.term()?;
while let Some(c) = self.peek() {
match c {
b'+' => {
self.i += 1;
v += self.term()?;
}
b'-' => {
self.i += 1;
v -= self.term()?;
}
_ => break,
}
}
self.depth -= 1;
Ok(v)
}
fn term(&mut self) -> std::result::Result<f64, String> {
let mut v = self.unary()?;
while let Some(c) = self.peek() {
match c {
b'*' => {
self.i += 1;
v *= self.unary()?;
}
b'/' => {
self.i += 1;
let d = self.unary()?;
if d == 0.0 {
return Err("division by zero".into());
}
v /= d;
}
_ => break,
}
}
Ok(v)
}
fn power(&mut self) -> std::result::Result<f64, String> {
let b = self.atom()?;
if self.peek() == Some(b'^') {
self.i += 1;
let e = self.unary()?;
return Ok(b.powf(e));
}
Ok(b)
}
fn unary(&mut self) -> std::result::Result<f64, String> {
match self.peek() {
Some(b'-') => {
self.i += 1;
Ok(-self.unary()?)
}
Some(b'+') => {
self.i += 1;
self.unary()
}
_ => self.power(),
}
}
fn atom(&mut self) -> std::result::Result<f64, String> {
match self.peek() {
Some(b'(') => {
self.i += 1;
let v = self.expr()?;
if self.peek() != Some(b')') {
return Err("missing )".into());
}
self.i += 1;
Ok(v)
}
Some(c) if c.is_ascii_digit() || c == b'.' => {
let st = self.i;
while self.s.get(self.i).is_some_and(|c| c.is_ascii_digit() || *c == b'.' || *c == b'e' || *c == b'E') {
self.i += 1;
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
t.parse::<f64>().map_err(|_| format!("bad number `{t}`"))
}
Some(c) if c.is_ascii_alphabetic() => {
let st = self.i;
while self.s.get(self.i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
self.i += 1;
}
let name = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("").to_ascii_lowercase();
if self.peek() == Some(b'(') {
self.i += 1;
let a = self.expr()?;
let b = if self.peek() == Some(b',') {
self.i += 1;
Some(self.expr()?)
} else {
None
};
if self.peek() != Some(b')') {
return Err("missing )".into());
}
self.i += 1;
return Ok(match (name.as_str(), b) {
("sin", _) => a.to_radians().sin(),
("cos", _) => a.to_radians().cos(),
("tan", _) => a.to_radians().tan(),
("asin", _) => a.asin().to_degrees(),
("acos", _) => a.acos().to_degrees(),
("atan", None) => a.atan().to_degrees(),
("atan", Some(x)) => a.atan2(x).to_degrees(),
("sqrt", _) => a.sqrt(),
("sqr", _) => a * a,
("abs", _) => a.abs(),
("ln", _) => a.ln(),
("log", _) => a.log10(),
("exp", _) => a.exp(),
("round", _) => a.round(),
("trunc", _) => a.trunc(),
("r2d", _) => a.to_degrees(),
("d2r", _) => a.to_radians(),
("min", Some(x)) => a.min(x),
("max", Some(x)) => a.max(x),
("pow", Some(x)) => a.powf(x),
_ => return Err(format!("unknown function `{name}`")),
});
}
match name.as_str() {
"pi" => Ok(std::f64::consts::PI),
"e" => Ok(std::f64::consts::E),
_ => self.vars.get(&name).and_then(Value::as_f64).ok_or_else(|| format!("unknown name `{name}`")),
}
}
_ => Err("expected a number".into()),
}
}
}
/// Evaluate an arithmetic expression (degrees for trig, `^` power, functions, pi).
pub fn eval(expr: &str, vars: &serde_json::Map<String, Value>) -> std::result::Result<f64, String> {
if expr.len() > 10_000 {
return Err("expression too long".into());
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
}
if !v.is_finite() {
return Err("result is not a finite number".into());
}
Ok(v)
}
fn run_cal(_s: &mut Session, p: &Value) -> Result<Value> {
let e = str_param(p, "expr").ok_or_else(|| bad("cal", "`expr` is required"))?;
let vars = p.get("vars").and_then(Value::as_object).cloned().unwrap_or_default();
let v = eval(e, &vars).map_err(|m| bad("cal", m))?;
Ok(json!({ "value": v, "message": format!("{v}") }))
}
// ---------------- FIND ----------------
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
if find.is_empty() {
return (text.to_string(), 0);
}
let hay = if case { text.to_string() } else { text.to_lowercase() };
let needle = if case { find.to_string() } else { find.to_lowercase() };
let mut out = String::new();
let mut n = 0;
let mut last = 0;
let mut start = 0;
while let Some(pos) = hay.get(start..).and_then(|h| h.find(&needle)).map(|p| p + start) {
let end = pos + needle.len();
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
if !whole || (boundary(pos) && after) {
out.push_str(text.get(last..pos).unwrap_or(""));
out.push_str(rep);
last = end;
n += 1;
}
start = end.max(pos + 1);
if start >= hay.len() {
break;
}
}
out.push_str(text.get(last..).unwrap_or(""));
(out, n)
}
fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
let find = str_param(p, "find").ok_or_else(|| bad("find", "`find` is required"))?.to_string();
let rep = str_param(p, "replace").map(str::to_string);
let case = bool_or(p, "matchCase", false);
let whole = bool_or(p, "wholeWord", false);
let space = s.space();
let d = s.doc()?;
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
let mut found: Vec<Value> = Vec::new();
let mut total = 0;
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
for h in hs {
let Some(e) = d.entity(h) else { continue };
let mut k = e.kind.clone();
let mut hits = 0;
let r = rep.as_deref().unwrap_or(&find);
let mut apply = |t: &mut String| {
let (nt, n) = replace_in(t, &find, r, case, whole);
if n > 0 {
hits += n;
if rep.is_some() {
*t = nt;
}
}
};
match &mut k {
EntityKind::Text(t) => apply(&mut t.value),
EntityKind::MText(t) => apply(&mut t.contents),
EntityKind::Insert(i) => i.attribs.iter_mut().for_each(|a| apply(&mut a.text.value)),
EntityKind::Dimension(dm) => apply(&mut dm.text),
EntityKind::MLeader(m) => {
if let Some(t) = &mut m.text {
apply(&mut t.contents)
}
}
EntityKind::Table(t) => t.cells.iter_mut().flatten().for_each(|c| apply(&mut c.text)),
_ => {}
}
if hits > 0 {
total += hits;
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
if rep.is_some() {
edits.push((h, k));
}
}
}
let n = edits.len();
if rep.is_some() {
let doc = s.doc_mut()?;
for (h, k) in edits {
doc.modify_entity(h, |e| {
e.kind = k;
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.block = None;
}
})?;
}
} else {
let sel: Vec<Handle> = found.iter().filter_map(|v| v["handle"].as_str().and_then(Handle::parse_hex)).collect();
s.set_selection(sel);
}
let msg =
if rep.is_some() { format!("{total} replacement(s) in {n} object(s)") } else { format!("{total} match(es) in {} object(s)", found.len()) };
Ok(json!({ "matches": found, "total": total, "message": msg }))
}
// ---------------- MASSPROP / MEASUREGEOM ----------------
/// Area, perimeter, centroid and second moments of a closed boundary.
fn region_props(pts: &[Vec2]) -> Option<(f64, f64, Vec2, f64, f64)> {
let n = pts.len();
if n < 3 {
return None;
}
let (mut a, mut cx, mut cy, mut ixx, mut iyy, mut per) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for i in 0..n {
let (p, q) = (*pts.get(i)?, *pts.get((i + 1) % n)?);
let c = p.cross(q);
a += c;
cx += (p.x + q.x) * c;
cy += (p.y + q.y) * c;
ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * c;
iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * c;
per += p.dist(q);
}
a /= 2.0;
if a.abs() < 1e-15 {
return None;
}
let c = Vec2::new(cx / (6.0 * a), cy / (6.0 * a));
Some((a.abs(), per, c, (ixx / 12.0).abs(), (iyy / 12.0).abs()))
}
fn closed_points(k: &EntityKind) -> Option<Vec<Vec2>> {
match k {
EntityKind::Circle(c) => {
let mut v = Vec::new();
cadcraft_geom::Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-5, &mut v);
v.pop();
Some(v)
}
EntityKind::LwPolyline(p) => {
let mut v = Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(1e-5);
if v.len() > 1 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
v.pop();
}
Some(v)
}
EntityKind::Hatch(h) => h.loops.first().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(1e-5)),
_ => None,
}
}
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let d = s.doc()?;
let mut out = Vec::new();
let mut lines = Vec::new();
for h in hs {
let Some(e) = d.entity(h) else { continue };
let Some((a, per, c, ixx, iyy)) = closed_points(&e.kind).and_then(|v| region_props(&v)) else { continue };
lines.push(format!("---------------- REGIONS ----------------\nArea: {a:.4}\nPerimeter: {per:.4}\nCentroid: X: {:.4} Y: {:.4}\nMoments of inertia: X: {ixx:.4} Y: {iyy:.4}", c.x, c.y));
out.push(json!({ "handle": h.hex(), "area": a, "perimeter": per, "centroid": [c.x, c.y], "ixx": ixx, "iyy": iyy }));
}
if out.is_empty() {
return Err(EngineError::Other("Select closed objects (regions, closed polylines, circles).".into()));
}
Ok(json!({ "objects": out, "message": lines.join("\n") }))
}
fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("radius.measure", "`handle` is required"))?;
let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Circle(c)) => c.radius,
Some(EntityKind::Arc(a)) => a.radius,
_ => return Err(bad("radius.measure", "select an arc or circle")),
};
Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) }))
}
fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) {
(v, a, b)
} else {
let hp = |k: &str| p.get(k).and_then(Value::as_str).and_then(Handle::parse_hex);
let (Some(h1), Some(h2)) = (hp("h1"), hp("h2")) else { return Err(bad("angle.measure", "give {vertex,p1,p2} or {h1,h2}")) };
let d = s.doc()?;
let line = |h: Handle| match d.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())),
_ => None,
};
let ((a1, a2), (b1, b2)) =
(line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?);
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?;
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
(x, far(a1, a2), far(b1, b2))
};
let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
if ang > 180.0 {
ang = 360.0 - ang;
}
Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") }))
}
fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "mode").unwrap_or("distance").to_ascii_lowercase().as_str() {
"distance" | "d" => s.execute("dist", p),
"radius" | "r" => run_radius(s, p),
"angle" | "a" => run_angle(s, p),
"area" | "ar" => s.execute("area", p),
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calculator() {
let v = serde_json::Map::new();
assert_eq!(eval("(3+4)*2^2", &v), Ok(28.0));
assert!((eval("sin(30)", &v).unwrap() - 0.5).abs() < 1e-12);
assert!((eval("2*pi", &v).unwrap() - std::f64::consts::TAU).abs() < 1e-12);
assert_eq!(eval("-2^2", &v), Ok(-4.0));
assert!(eval("1/0", &v).is_err());
assert!(eval("((((1", &v).is_err());
assert!(eval("foo(1)", &v).is_err());
assert!(eval(&"(".repeat(5000), &v).is_err());
let mut vars = serde_json::Map::new();
vars.insert("x".into(), json!(3));
assert_eq!(eval("x*x", &vars), Ok(9.0));
}
#[test]
fn find_and_replace() {
assert_eq!(replace_in("Bolt bolt BOLTS", "bolt", "Pin", false, true), ("Pin Pin BOLTS".to_string(), 2));
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
}
#[test]
fn rectangle_mass_properties() {
let (a, per, c, _, _) = region_props(&[Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(4.0, 2.0), Vec2::new(0.0, 2.0)]).unwrap();
assert!((a - 8.0).abs() < 1e-12);
assert!((per - 12.0).abs() < 1e-12);
assert!(c.near(Vec2::new(2.0, 1.0), 1e-12));
}
}
+329
View File
@@ -0,0 +1,329 @@
//! Grip editing: dragging an object's grips (the points from [`EntityKind::grips`]) the way
//! AutoCAD does, plus the multi-functional grip modes (move, rotate, scale, mirror about a grip).
//!
//! The UI calls [`Session::grip_edit`]; every edit runs through a `grip.*` command so it is
//! undoable and reachable from scripts, the control channel and MCP.
use cadcraft_doc::{DimKind, EntityKind, Handle, LwPolyline};
use cadcraft_geom::{Arc, EPS, Mat3, Polyline, Spline, Vec2, Vec3, norm_angle};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{EngineError, Result, Session};
/// What dragging a hot grip does (AutoCAD's grip modes, cycled with Space/Enter).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GripMode {
/// Move the grip itself (the object's shape changes).
#[default]
Stretch,
/// Move the whole object by the grip displacement.
Move,
/// Rotate the object about the grip; the angle is from the grip to the new point.
Rotate,
/// Scale the object about the grip; the factor is the distance from the grip to the new point.
Scale,
/// Mirror the object about the line from the grip to the new point.
Mirror,
}
fn set_xy(p: &mut Vec3, q: Vec2) {
p.x = q.x;
p.y = q.y;
}
fn translated(k: &EntityKind, d: Vec2) -> EntityKind {
let mut k = k.clone();
k.transform(&Mat3::translate(d));
k
}
/// What a polyline grip index refers to (matches the order of `EntityKind::grips`).
enum PolyGrip {
Vertex(usize),
/// Midpoint of the segment starting at this vertex.
Mid(usize),
}
fn poly_grip(p: &LwPolyline, index: usize) -> Option<PolyGrip> {
let n = p.vertices.len();
// Start vertices of the non-degenerate segments, in the same order as Polyline::segments().
let count = if p.closed { n } else { n.saturating_sub(1) };
let mut seg_starts = Vec::with_capacity(count);
for i in 0..count {
let (Some(v), Some(w)) = (p.vertices.get(i), p.vertices.get((i + 1) % n.max(1))) else { continue };
if v.p.near(w.p, EPS) && v.bulge.abs() < 1e-12 {
continue;
}
seg_starts.push(i);
}
let mut k = 0usize;
for i in 0..n {
if k == index {
return Some(PolyGrip::Vertex(i));
}
k += 1;
if let Some(start) = seg_starts.get(i) {
if k == index {
return Some(PolyGrip::Mid(*start));
}
k += 1;
}
}
None
}
/// The shape after dragging grip `index` to `to` (STRETCH mode). `None` when the grip does not
/// exist or the result would be degenerate.
pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityKind> {
if !to.is_finite() {
return None;
}
let grips = kind.grips();
let from = *grips.get(index)?;
let delta = to - from;
let mut k = kind.clone();
match &mut k {
EntityKind::Line(l) => match index {
0 => set_xy(&mut l.a, to),
1 => return Some(translated(kind, delta)),
_ => set_xy(&mut l.b, to),
},
EntityKind::Point(_) | EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Image(_) | EntityKind::Table(_) => {
return Some(translated(kind, delta));
}
EntityKind::Hatch(_) => return Some(translated(kind, delta)),
EntityKind::Circle(c) => {
if index == 0 {
return Some(translated(kind, delta));
}
let r = c.center.xy().dist(to);
if r <= EPS {
return None;
}
c.radius = r;
}
EntityKind::Arc(a) => {
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
let (mut s, mut m, mut e) = (g.start_point(), g.mid_point(), g.end_point());
match index {
0 => s = to,
1 => m = to,
2 => e = to,
_ => return Some(translated(kind, delta)),
}
let na = Arc::from_3_points(s, m, e)?;
return Some(crate::cmd::helpers::arc(&na));
}
EntityKind::Ellipse(e) => {
let c = e.center.xy();
let mj = e.major.xy();
let a = mj.len();
if a <= EPS {
return None;
}
let b = a * e.ratio;
match index {
0 => return Some(translated(kind, delta)),
1 | 3 => {
let v = if index == 1 { to - c } else { c - to };
let l = v.len();
if l <= EPS {
return None;
}
if l >= b {
e.major = v.to3(0.0);
e.ratio = (b / l).clamp(1e-9, 1.0);
} else {
e.major = (v.normalized().perp() * b).to3(0.0);
e.ratio = (l / b).clamp(1e-9, 1.0);
shift_params(e, -std::f64::consts::FRAC_PI_2);
}
}
_ => {
let u = mj / a;
let l = (to - c).dot(u.perp()).abs();
if l <= EPS {
return None;
}
if l <= a {
e.ratio = (l / a).clamp(1e-9, 1.0);
} else {
e.major = (u.perp() * l).to3(0.0);
e.ratio = (a / l).clamp(1e-9, 1.0);
shift_params(e, -std::f64::consts::FRAC_PI_2);
}
}
}
}
EntityKind::LwPolyline(p) => {
let n = p.vertices.len();
match poly_grip(p, index)? {
PolyGrip::Vertex(i) => p.vertices.get_mut(i)?.p = to,
PolyGrip::Mid(i) => {
let j = (i + 1) % n.max(1);
let (a, b) = (p.vertices.get(i)?.p, p.vertices.get(j)?.p);
if p.vertices.get(i)?.bulge.abs() > 1e-12 {
// Arc segment: reshape through the dragged midpoint.
p.vertices.get_mut(i)?.bulge = crate::cmd::curves::bulge_through(a, to, b);
} else {
p.vertices.get_mut(i)?.p = a + delta;
p.vertices.get_mut(j)?.p = b + delta;
}
}
}
}
EntityKind::Polyline3d(p) => set_xy(p.points.get_mut(index)?, to),
EntityKind::Spline(sp) => {
if sp.fit.is_empty() {
*sp.control.get_mut(index)? = to;
} else {
let mut fit = sp.fit.clone();
*fit.get_mut(index)? = to;
let closed = sp.closed;
let mut n = Spline::from_fit_points(&fit);
n.closed = closed;
*sp = n;
}
}
EntityKind::Ray(r) | EntityKind::XLine(r) => {
if index == 0 {
return Some(translated(kind, delta));
}
let d = (to - r.base.xy()).normalized();
if d == Vec2::ZERO {
return None;
}
r.dir = d.to3(0.0);
}
EntityKind::Insert(i) => {
if index == 0 {
return Some(translated(kind, delta));
}
let a = i.attribs.get_mut(index - 1)?;
let ip = a.text.insert.xy() + delta;
set_xy(&mut a.text.insert, ip);
if let Some(al) = &mut a.text.align_pt {
let q = al.xy() + delta;
set_xy(al, q);
}
}
EntityKind::Dimension(d) => {
match index {
0 => set_xy(&mut d.p13, to),
1 => set_xy(&mut d.p14, to),
2 => {
set_xy(&mut d.defpt, to);
if matches!(d.kind, DimKind::Radius | DimKind::Diameter) {
d.user_text_pos = false;
}
}
_ => {
set_xy(&mut d.text_mid, to);
d.user_text_pos = true;
}
}
d.block = None;
}
EntityKind::Leader(l) => set_xy(l.vertices.get_mut(index)?, to),
EntityKind::MLeader(m) => {
let mut idx = 0usize;
let mut done = false;
'outer: for l in &mut m.leaders {
for v in l.iter_mut() {
if idx == index {
set_xy(v, to);
done = true;
break 'outer;
}
idx += 1;
}
}
if !done {
// The landing grip moves the landing and its text.
let ld = m.landing.xy() + delta;
set_xy(&mut m.landing, ld);
if let Some(t) = &mut m.text {
let q = t.insert.xy() + delta;
set_xy(&mut t.insert, q);
}
}
}
EntityKind::Solid(s) | EntityKind::Trace(s) => set_xy(s.corners.get_mut(index)?, to),
EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to),
EntityKind::Viewport(v) => {
let opposite = *grips.get((index + 2) % 4)?;
let w = (to.x - opposite.x).abs();
let h = (to.y - opposite.y).abs();
if w <= EPS || h <= EPS {
return None;
}
let c = to.mid(opposite);
set_xy(&mut v.center, c);
v.width = w;
v.height = h;
}
EntityKind::Wipeout(w) => *w.boundary.get_mut(index)? = to,
EntityKind::Unknown(_) => return None,
}
Some(k)
}
fn shift_params(e: &mut cadcraft_doc::Ellipse, by: f64) {
let full = (cadcraft_geom::ccw_sweep(e.start, e.end) - cadcraft_geom::TAU).abs() < 1e-9;
if !full {
e.start = norm_angle(e.start + by);
e.end = norm_angle(e.end + by);
}
}
/// The transform for a multi-functional grip mode about `base` with the dragged point `to`.
pub fn mode_matrix(mode: GripMode, base: Vec2, to: Vec2) -> Option<Mat3> {
match mode {
GripMode::Stretch => None,
GripMode::Move => Some(Mat3::translate(to - base)),
GripMode::Rotate => Some(Mat3::rotate_about(base, base.angle_to(to))),
GripMode::Scale => {
let f = base.dist(to);
(f > EPS && f.is_finite()).then(|| Mat3::scale_about(base, f))
}
GripMode::Mirror => (!base.near(to, EPS)).then(|| Mat3::mirror(base, to)),
}
}
impl Session {
/// Drag grip `grip_index` of `handle` to `new_point` in `mode` (one undo step).
/// The base point of the rotate/scale/mirror modes is the grip itself.
pub fn grip_edit(&mut self, handle: Handle, grip_index: usize, new_point: Vec2, mode: GripMode) -> Result<()> {
let to = [new_point.x, new_point.y];
let r = match mode {
GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "index": grip_index, "to": to })),
GripMode::Move => self.execute("grip.move", &json!({ "handles": [handle.hex()], "index": grip_index, "to": to, "mode": "move" })),
GripMode::Rotate => {
self.execute("grip.rotate", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
}
GripMode::Scale => {
self.execute("grip.scale", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
}
GripMode::Mirror => {
self.execute("grip.mirror", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
}
};
r.map(|_| ())
}
/// Grip points of an object (the indices `grip_edit` takes).
pub fn grips_of(&self, handle: Handle) -> Result<Vec<Vec2>> {
self.doc()?.entity(handle).map(|e| e.kind.grips()).ok_or_else(|| EngineError::Other("no such object".into()))
}
}
/// Polyline helper kept public for the UI: segment midpoints are grips too.
pub fn polyline_grip_count(p: &LwPolyline) -> usize {
p.vertices.len() + Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments().len().min(p.vertices.len())
}
#[cfg(test)]
#[path = "grips_tests.rs"]
mod tests;
+224
View File
@@ -0,0 +1,224 @@
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Arc, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::Session;
fn h(r: &Value) -> Handle {
Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap()
}
fn kind(s: &Session, h: Handle) -> EntityKind {
s.doc().unwrap().entity(h).unwrap().kind.clone()
}
fn near(a: Vec2, x: f64, y: f64) -> bool {
a.near(Vec2::new(x, y), 1e-6)
}
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
#[test]
fn line_and_circle_grips() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.grip_edit(l, 0, v(1.0, 1.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 1.0, 1.0) && near(x.b.xy(), 10.0, 0.0)));
// Midpoint grip moves the whole line.
let mid = s.grips_of(l).unwrap()[1];
s.grip_edit(l, 1, mid + v(0.0, 5.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 1.0, 6.0) && near(x.b.xy(), 10.0, 5.0)));
s.execute("grip.move", &json!({"handle": l.hex(), "index": 2, "to": [20, 5]})).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 20.0, 5.0)));
// Each edit is one undo step.
s.undo().unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 10.0, 5.0)));
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap());
s.grip_edit(c, 2, v(0.0, 5.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, c), EntityKind::Circle(x) if (x.radius - 5.0).abs() < 1e-9));
s.grip_edit(c, 0, v(3.0, 3.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, c), EntityKind::Circle(x) if near(x.center.xy(), 3.0, 3.0) && (x.radius - 5.0).abs() < 1e-9));
assert!(s.grip_edit(c, 1, v(3.0, 3.0), GripMode::Stretch).is_err(), "zero radius refused");
assert!(s.grip_edit(c, 99, v(3.0, 3.0), GripMode::Stretch).is_err());
}
#[test]
fn arc_and_ellipse_grips() {
let mut s = Session::new();
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 180})).unwrap());
// Midpoint grip: three-point reshape through the new point.
s.grip_edit(a, 1, v(0.0, 2.0), GripMode::Stretch).unwrap();
let EntityKind::Arc(x) = kind(&s, a) else { panic!() };
let g = Arc::new(x.center.xy(), x.radius, x.start, x.end);
assert!(near(g.start_point(), 1.0, 0.0) && near(g.end_point(), -1.0, 0.0) && near(g.mid_point(), 0.0, 2.0));
// End grip.
s.grip_edit(a, 2, v(-3.0, 0.0), GripMode::Stretch).unwrap();
let EntityKind::Arc(x) = kind(&s, a) else { panic!() };
assert!(near(Arc::new(x.center.xy(), x.radius, x.start, x.end).end_point(), -3.0, 0.0));
// Center grip moves.
let c0 = s.grips_of(a).unwrap()[3];
s.grip_edit(a, 3, c0 + v(10.0, 0.0), GripMode::Stretch).unwrap();
let EntityKind::Arc(y) = kind(&s, a) else { panic!() };
assert!((y.center.x - x.center.x - 10.0).abs() < 1e-9 && (y.radius - x.radius).abs() < 1e-9);
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap());
s.grip_edit(e, 2, v(0.0, 1.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if (x.ratio - 0.25).abs() < 1e-9));
s.grip_edit(e, 1, v(0.0, 6.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if near(x.major.xy(), 0.0, 6.0) && (x.ratio - 1.0 / 6.0).abs() < 1e-9));
// Dragging the minor grip past the major length swaps the axes.
s.grip_edit(e, 2, v(-8.0, 0.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if (x.major.xy().len() - 8.0).abs() < 1e-9 && (x.ratio - 0.75).abs() < 1e-9));
}
#[test]
fn polyline_grips() {
let mut s = Session::new();
let p = h(&s.execute("pline", &json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 0.0}, {"p": [10, 10], "bulge": 1.0}, [0, 10]]})).unwrap());
// Grips: v0, m0, v1, m1, v2, m2, v3.
assert_eq!(s.grips_of(p).unwrap().len(), 7);
s.grip_edit(p, 0, v(-1.0, -1.0), GripMode::Stretch).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, p) else { panic!() };
assert!(near(pl.vertices[0].p, -1.0, -1.0));
// Line segment midpoint: moves the segment.
let m1 = s.grips_of(p).unwrap()[3];
s.grip_edit(p, 3, m1 + v(2.0, 0.0), GripMode::Stretch).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, p) else { panic!() };
assert!(near(pl.vertices[1].p, 12.0, 0.0) && near(pl.vertices[2].p, 12.0, 10.0));
// Arc segment midpoint: adjusts the bulge so the arc passes through the point.
s.grip_edit(p, 5, v(6.0, 12.0), GripMode::Stretch).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, p) else { panic!() };
assert!(near(pl.vertices[2].p, 12.0, 10.0) && near(pl.vertices[3].p, 0.0, 10.0));
let segs = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: false }.segments();
assert!(segs[2].mid().near(v(6.0, 12.0), 1e-6), "{:?}", segs[2].mid());
assert!(s.grip_edit(p, 7, v(0.0, 0.0), GripMode::Stretch).is_err());
// Closed polylines have a closing-segment midpoint grip.
let r = h(&s.execute("rectang", &json!({"p1": [20, 0], "p2": [24, 2]})).unwrap());
assert_eq!(s.grips_of(r).unwrap().len(), 8);
s.grip_edit(r, 7, v(19.0, 1.0), GripMode::Stretch).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, r) else { panic!() };
assert!(near(pl.vertices[3].p, 19.0, 2.0) && near(pl.vertices[0].p, 19.0, 0.0));
// A zero-length segment keeps grip indices aligned with EntityKind::grips().
let d = h(&s.execute("pline", &json!({"vertices": [[0, 50], [0, 50], [5, 50]]})).unwrap());
let g = s.grips_of(d).unwrap();
assert_eq!(g.len(), 4);
s.grip_edit(d, 3, v(5.0, 55.0), GripMode::Stretch).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, d) else { panic!() };
assert!(near(pl.vertices[2].p, 5.0, 55.0), "{pl:?}");
}
#[test]
fn spline_text_insert_and_dimension_grips() {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0]]})).unwrap());
s.grip_edit(sp, 1, v(2.0, 4.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, sp), EntityKind::Spline(x) if near(x.fit[1], 2.0, 4.0) && near(x.eval(x.domain().0), 0.0, 0.0)));
let cv = h(&s.execute("spline", &json!({"control": [[0, 10], [2, 12], [4, 10]]})).unwrap());
s.grip_edit(cv, 2, v(5.0, 9.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, cv), EntityKind::Spline(x) if near(x.control[2], 5.0, 9.0)));
let t = h(&s.execute("text", &json!({"at": [1, 1], "text": "Hi", "justify": "C"})).unwrap());
s.grip_edit(t, 0, v(3.0, 4.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, t), EntityKind::Text(x) if near(x.insert.xy(), 3.0, 4.0) && near(x.align_pt.unwrap().xy(), 3.0, 4.0)));
let dot = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 0.1})).unwrap());
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": [dot.hex()], "keep": "delete"})).unwrap();
let ins = h(&s.execute("insert", &json!({"name": "B", "at": [5, 5]})).unwrap());
s.grip_edit(ins, 0, v(6.0, 7.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, ins), EntityKind::Insert(x) if near(x.insert.xy(), 6.0, 7.0)));
let d = h(&s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 2]})).unwrap());
let g = s.grips_of(d).unwrap();
assert_eq!(g.len(), 4);
s.grip_edit(d, 3, v(5.0, 6.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, d), EntityKind::Dimension(x) if x.user_text_pos && near(x.text_mid.xy(), 5.0, 6.0)));
s.grip_edit(d, 1, v(12.0, 0.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, d), EntityKind::Dimension(x) if near(x.p14.xy(), 12.0, 0.0)));
}
#[test]
fn other_entity_grips() {
let mut s = Session::new();
let x = h(&s.execute("xline", &json!({"base": [0, 0], "angle": 0})).unwrap());
s.grip_edit(x, 1, v(0.0, 3.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, x), EntityKind::XLine(r) if near(r.dir.xy(), 0.0, 1.0)));
let p3 = h(&s.execute("3dpoly", &json!({"points": [[0, 0, 4], [1, 0, 5]]})).unwrap());
s.grip_edit(p3, 1, v(2.0, 2.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, p3), EntityKind::Polyline3d(p) if near(p.points[1].xy(), 2.0, 2.0) && p.points[1].z == 5.0));
let w = h(&s.execute("wipeout", &json!({"points": [[0, 0], [2, 0], [2, 2]]})).unwrap());
s.grip_edit(w, 2, v(3.0, 3.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, w), EntityKind::Wipeout(p) if near(p.boundary[2], 3.0, 3.0)));
let pt = h(&s.execute("point", &json!({"at": [1, 1]})).unwrap());
s.grip_edit(pt, 0, v(2.0, 2.0), GripMode::Stretch).unwrap();
assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0)));
}
#[test]
fn grip_modes() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap());
// Move about grip 0.
s.grip_edit(l, 0, v(1.0, 1.0), GripMode::Move).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 1.0, 1.0) && near(x.b.xy(), 3.0, 1.0)));
// Rotate about grip 0 by the angle to the new point (90°).
s.grip_edit(l, 0, v(1.0, 5.0), GripMode::Rotate).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, 3.0)));
// Scale about grip 0 by the distance (3).
s.grip_edit(l, 0, v(4.0, 1.0), GripMode::Scale).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, 7.0)));
// Mirror about the line from grip 0 to (2, 1): a horizontal line through y=1.
s.grip_edit(l, 0, v(2.0, 1.0), GripMode::Mirror).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, -5.0)));
assert_eq!(s.state().unwrap().undo.len(), 5);
// JSON forms with explicit base, angle, factor and copy.
let r = s.execute("grip.rotate", &json!({"handles": [l.hex()], "base": [1, 1], "angle": 180, "copy": true})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 1);
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("grip.scale", &json!({"handles": [l.hex()], "base": [1, 1], "factor": 0.5})).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, -2.0)));
s.execute("grip.move", &json!({"handles": [l.hex()], "base": [1, 1], "to": [2, 2]})).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 2.0, 2.0)));
s.execute("grip.mirror", &json!({"handles": [l.hex()], "baseHandle": l.hex(), "index": 0, "to": [3, 2]})).unwrap();
s.execute("grip.stretch", &json!({"handle": l.hex(), "index": 2, "to": [9, 9]})).unwrap();
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 9.0, 9.0)));
assert!(s.execute("grip.scale", &json!({"handles": [l.hex()], "base": [1, 1], "factor": 0})).is_err());
assert!(s.execute("grip.mirror", &json!({"handles": [l.hex()], "base": [1, 1], "to": [1, 1]})).is_err());
assert!(s.execute("grip.move", &json!({"handle": l.hex(), "to": [1, 1]})).is_err(), "stretch needs an index");
}
#[test]
fn grips_on_locked_layers_and_hostile_values() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap());
for (i, p) in [(0usize, v(f64::NAN, 0.0)), (1, v(f64::INFINITY, 1.0)), (usize::MAX, v(1.0, 1.0))] {
assert!(s.grip_edit(l, i, p, GripMode::Stretch).is_err());
}
for mode in [GripMode::Move, GripMode::Rotate, GripMode::Scale, GripMode::Mirror] {
let _ = s.grip_edit(l, 0, v(0.0, 0.0), mode);
let _ = s.grip_edit(l, 42, v(1e308, -1e308), mode);
}
s.execute("layer.new", &json!({"name": "L"})).ok();
s.doc_mut().unwrap().ensure_layer("LOCKED");
s.doc_mut().unwrap().layer_mut("LOCKED").unwrap().locked = true;
s.doc_mut().unwrap().modify_entity(l, |e| e.common.layer = "LOCKED".into()).unwrap();
assert!(s.grip_edit(l, 0, v(5.0, 5.0), GripMode::Stretch).is_err());
// Every kind accepts every grip index without panicking.
let mut s = Session::new();
crate::cmd::command_specs();
let sample = crate::sample::bracket();
s.open_drawing(sample, "sample", None);
let hs: Vec<(Handle, usize)> = s.doc().unwrap().model.iter().map(|e| (e.handle, e.kind.grips().len())).collect();
for (hd, n) in hs {
for i in 0..n + 1 {
for to in [v(0.0, 0.0), v(1e9, -1e9), v(3.0, 4.0)] {
if let Some(k) = s.doc().unwrap().entity(hd).map(|e| e.kind.clone()) {
let _ = stretch_grip(&k, i, to);
}
}
}
}
}
+2
View File
@@ -9,10 +9,12 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod grips;
pub mod prompt;
pub mod sample;
pub mod select;
pub mod snap;
pub mod spatial;
pub mod sysvars;
pub mod units;
+64 -12
View File
@@ -3,6 +3,8 @@
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Prim, Space, entity_bounds};
use cadcraft_geom::{Bounds2, Line, Vec2};
use crate::spatial::{self, SpatialIndex};
/// Polylines approximating an entity for hit testing, tessellated at `tol`.
pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
match &e.kind {
@@ -83,18 +85,57 @@ fn selectable(d: &Drawing, e: &Entity) -> bool {
d.is_visible(e)
}
/// Entities to test, in the order to test them, each with its bounds when already known.
pub(crate) type Cands<'a> = Box<dyn Iterator<Item = (&'a Entity, Option<Bounds2>)> + 'a>;
pub(crate) fn bounds_of(d: &Drawing, e: &Entity, known: Option<Bounds2>) -> Bounds2 {
known.unwrap_or_else(|| entity_bounds(d, e, 0))
}
pub(crate) fn is_infinite(e: &Entity) -> bool {
matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_))
}
/// Candidates from the spatial index touching `query` (draw order, reversed when `rev`), or every
/// entity of the space when it is too small to index (or the query box isn't finite).
pub(crate) fn candidates<'a>(
d: &'a Drawing,
space: &Space,
ix: &'a Option<std::sync::Arc<SpatialIndex>>,
query: &Bounds2,
infinite: bool,
rev: bool,
) -> Option<Cands<'a>> {
if let Some(ix) = ix
&& let Some(c) = ix.query(query, infinite)
{
let it = move |i: u32| ix.entity(i).map(|e| (e.as_ref(), Some(ix.bounds(i))));
return Some(if rev { Box::new(c.into_iter().rev().filter_map(it)) } else { Box::new(c.into_iter().filter_map(it)) });
}
let store = d.space(space)?;
if rev {
let ents: Vec<&Entity> = store.iter().map(|e| e.as_ref()).collect();
Some(Box::new(ents.into_iter().rev().map(|e| (e, None))))
} else {
Some(Box::new(store.iter().map(|e| (e.as_ref(), None))))
}
}
/// The topmost entity within `aperture` of `p`.
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
let store = d.space(space)?;
let ix = spatial::index(d, space);
pick_with(d, space, &ix, p, aperture)
}
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
let probe = Bounds2::new(p, p).expand(aperture);
let mut best: Option<(f64, Handle)> = None;
let ents: Vec<_> = store.iter().collect();
for e in ents.iter().rev() {
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
if !selectable(d, e) {
continue;
}
let infinite = matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_));
if !infinite && !entity_bounds(d, e, 0).expand(aperture).intersects(&probe) {
let infinite = is_infinite(e);
if !infinite && !bounds_of(d, e, known).expand(aperture).intersects(&probe) {
continue;
}
let dist = entity_distance(d, e, p, aperture / 4.0);
@@ -118,15 +159,20 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
/// Window selection (entirely inside) or crossing selection (inside or touching).
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
let Some(store) = d.space(space) else { return Vec::new() };
let ix = spatial::index(d, space);
select_window_with(d, space, &ix, bx, crossing)
}
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
let tol = (bx.width() + bx.height()).max(1e-9) / 2000.0;
let mut out = Vec::new();
for e in store.iter() {
for (e, known) in cands {
if !selectable(d, e) {
continue;
}
let infinite = matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_));
let eb = entity_bounds(d, e, 0);
let infinite = is_infinite(e);
let eb = bounds_of(d, e, known);
if !infinite && bx.contains_box(&eb) {
out.push(e.handle);
continue;
@@ -152,12 +198,18 @@ pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) ->
/// Fence selection: entities crossed by the fence polyline.
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
let Some(store) = d.space(space) else { return Vec::new() };
let ix = spatial::index(d, space);
select_fence_with(d, space, &ix, fence)
}
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
let fb = Bounds2::from_points(fence.iter().copied());
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
let probe = fb.expand(tol);
let Some(cands) = candidates(d, space, ix, &probe, false, false) else { return Vec::new() };
let mut out = Vec::new();
for e in store.iter() {
if !selectable(d, e) || !entity_bounds(d, e, 0).intersects(&fb.expand(tol)) {
for (e, known) in cands {
if !selectable(d, e) || !bounds_of(d, e, known).intersects(&probe) {
continue;
}
let polys = hit_polylines(d, e, tol);
+20 -5
View File
@@ -1,6 +1,6 @@
//! Object snaps (OSNAP), polar tracking and ortho.
use cadcraft_doc::{Drawing, EntityKind, Prim, Space, entity_bounds};
use cadcraft_doc::{Drawing, EntityKind, Prim, Space};
use cadcraft_geom::{Bounds2, Circle, PI, Segment, Vec2, intersect_ext};
use serde::Serialize;
@@ -79,17 +79,32 @@ pub fn osnap(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u3
if osmode == 0 || osmode & mode::OFF != 0 {
return None;
}
let store = d.space(space)?;
let ix = crate::spatial::index(d, space);
osnap_with(d, space, &ix, cursor, aperture, osmode, base)
}
pub(crate) fn osnap_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
cursor: Vec2,
aperture: f64,
osmode: u32,
base: Option<Vec2>,
) -> Option<SnapHit> {
if osmode == 0 || osmode & mode::OFF != 0 {
return None;
}
let probe = Bounds2::new(cursor, cursor).expand(aperture);
let mut cands: Vec<(u32, Vec2)> = Vec::new();
let mut near_prims: Vec<Prim> = Vec::new();
let mut count = 0usize;
for e in store.iter() {
for (e, known) in crate::select::candidates(d, space, ix, &probe.expand(aperture), true, false)? {
if !d.is_visible(e) {
continue;
}
let inf = matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_));
if !inf && !entity_bounds(d, e, 0).expand(aperture).intersects(&probe) {
let inf = crate::select::is_infinite(e);
if !inf && !crate::select::bounds_of(d, e, known).expand(aperture).intersects(&probe) {
continue;
}
count += 1;
+373
View File
@@ -0,0 +1,373 @@
//! R-tree spatial index over entity bounds, used by picking, window/crossing/fence selection and
//! object snaps so they look only at entities near the query instead of scanning the space.
//!
//! The index is cached per drawing space and keyed by *content identity*, not by a revision
//! number: the cache holds a clone of the space's [`EntityStore`] (a cheap copy of chunk
//! pointers) and of the block table, and an entry is reused only while the live store still
//! shares every chunk with it ([`EntityStore::same_as`]). Because the cache holds a reference,
//! any edit copies the touched chunk, so a stale index can never be returned — whether the edit
//! came from a command, an interactive prompt mid-command, undo/redo or a script. Undoing back to
//! a cached state reuses its index.
//!
//! Queries return candidates in draw order; callers then run exactly the same tests as the
//! brute-force scan, so results are identical (see the parity tests).
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds};
use cadcraft_geom::Bounds2;
use rstar::primitives::{GeomWithData, Rectangle};
use rstar::{AABB, RTree};
/// Spaces with fewer entities than this are scanned directly (building an index costs more).
pub const MIN_INDEXED: usize = 256;
/// Indexes kept alive (open drawings × spaces, plus a few undo states).
const CACHE_SIZE: usize = 4;
type Item = GeomWithData<Rectangle<[f64; 2]>, u32>;
/// An R-tree over one space's entity bounds.
pub struct SpatialIndex {
store: EntityStore,
blocks: BTreeMap<String, Arc<Block>>,
dim_styles: Vec<DimStyle>,
dimscale: f64,
ents: Vec<Arc<Entity>>,
bounds: Vec<Bounds2>,
tree: RTree<Item>,
/// Draw indices of xlines and rays (unbounded; always candidates for pick and crossing).
infinite: Vec<u32>,
}
impl SpatialIndex {
/// Build an index over a store (no caching).
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
let mut ents = Vec::with_capacity(store.len());
let mut bounds = Vec::with_capacity(store.len());
let mut items = Vec::with_capacity(store.len());
let mut infinite = Vec::new();
for (i, e) in store.iter().enumerate() {
let Ok(idx) = u32::try_from(i) else { break };
let b = entity_bounds(d, e, 0);
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
infinite.push(idx);
}
if !b.is_empty() {
items.push(GeomWithData::new(Rectangle::from_corners([b.min.x, b.min.y], [b.max.x, b.max.y]), idx));
}
ents.push(e.clone());
bounds.push(b);
}
SpatialIndex {
store: store.clone(),
blocks: d.blocks.clone(),
dim_styles: d.dim_styles.clone(),
dimscale: d.header.f64("DIMSCALE", 1.0),
ents,
bounds,
tree: RTree::bulk_load(items),
infinite,
}
}
fn matches(&self, d: &Drawing, store: &EntityStore) -> bool {
self.store.same_as(store)
&& self.blocks.len() == d.blocks.len()
&& self.blocks.iter().zip(&d.blocks).all(|((ka, a), (kb, b))| ka == kb && Arc::ptr_eq(a, b))
&& self.dimscale.to_bits() == d.header.f64("DIMSCALE", 1.0).to_bits()
&& self.dim_styles == d.dim_styles
}
pub fn len(&self) -> usize {
self.ents.len()
}
pub fn is_empty(&self) -> bool {
self.ents.is_empty()
}
/// The entity at draw index `i`.
pub fn entity(&self, i: u32) -> Option<&Arc<Entity>> {
self.ents.get(i as usize)
}
/// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them).
pub fn bounds(&self, i: u32) -> Bounds2 {
self.bounds.get(i as usize).copied().unwrap_or(Bounds2::EMPTY)
}
/// Draw indices (ascending) of entities whose bounds touch `b`, optionally with the infinite
/// lines. Returns `None` for a non-finite query box (callers fall back to a full scan).
pub fn query(&self, b: &Bounds2, with_infinite: bool) -> Option<Vec<u32>> {
if b.is_empty() {
return Some(if with_infinite { self.infinite.clone() } else { Vec::new() });
}
if !(b.min.is_finite() && b.max.is_finite()) {
return None;
}
// Pad a little so rounding in the callers' own predicates never loses a candidate.
let pad = (b.width() + b.height()) * 1e-9 + (b.min.x.abs() + b.min.y.abs() + b.max.x.abs() + b.max.y.abs()) * 1e-12 + 1e-300;
let env = AABB::from_corners([b.min.x - pad, b.min.y - pad], [b.max.x + pad, b.max.y + pad]);
let mut out: Vec<u32> = self.tree.locate_in_envelope_intersecting(&env).map(|it| it.data).collect();
if with_infinite {
out.extend_from_slice(&self.infinite);
}
out.sort_unstable();
out.dedup();
Some(out)
}
}
static CACHE: Mutex<Vec<Arc<SpatialIndex>>> = Mutex::new(Vec::new());
/// The cached index for a space, building it when the space changed. `None` when the space
/// doesn't exist or is small enough to scan directly.
pub fn index(d: &Drawing, space: &Space) -> Option<Arc<SpatialIndex>> {
let store = d.space(space)?;
if store.len() < MIN_INDEXED {
return None;
}
Some(index_store(d, store))
}
/// The cached index for a store regardless of its size.
pub fn index_store(d: &Drawing, store: &EntityStore) -> Arc<SpatialIndex> {
{
let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(pos) = cache.iter().position(|ix| ix.matches(d, store)) {
let ix = cache.remove(pos);
cache.push(ix.clone());
return ix;
}
}
// Build outside the lock so other threads aren't blocked.
let ix = Arc::new(SpatialIndex::build(d, store));
let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Drop entries whose store this one supersedes cheaply: keep the most recent ones.
while cache.len() >= CACHE_SIZE {
cache.remove(0);
}
cache.push(ix.clone());
ix
}
/// Drop every cached index (frees memory held by closed drawings).
pub fn clear_cache() {
CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::{select, snap};
use cadcraft_doc::{Common, Insert, Text};
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
struct Rng(u64);
impl Rng {
fn f(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
fn r(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.f()
}
}
fn v3(x: f64, y: f64) -> Vec3 {
Vec3::new(x, y, 0.0)
}
/// A random drawing mixing every kind the selection code special-cases, overlapping heavily.
fn drawing(seed: u64, n: usize) -> Drawing {
let mut d = Drawing::new_imperial();
let mut blk = Block {
name: "B".into(),
base: Vec3::default(),
entities: EntityStore::new(),
description: String::new(),
anonymous: false,
xref_path: None,
explodable: true,
units: 0,
};
blk.entities
.push(Entity::new(cadcraft_doc::Handle(1_000_000), EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 1.0 })));
d.blocks.insert("B".into(), Arc::new(blk));
d.ensure_layer("OFF");
if let Some(l) = d.layer_mut("OFF") {
l.on = false;
}
let mut r = Rng(seed.max(1));
for i in 0..n {
let (x, y) = (r.r(0.0, 100.0), r.r(0.0, 100.0));
let s = r.r(0.2, 6.0);
let kind = match i % 10 {
0 | 1 => EntityKind::Line(cadcraft_doc::Line { a: v3(x, y), b: v3(x + r.r(-s, s), y + r.r(-s, s)) }),
2 => EntityKind::Circle(cadcraft_doc::Circle { center: v3(x, y), radius: s * 0.5 }),
3 => EntityKind::Arc(cadcraft_doc::Arc { center: v3(x, y), radius: s * 0.5, start: r.r(0.0, 3.0), end: r.r(3.2, 6.2) }),
4 => EntityKind::LwPolyline(cadcraft_doc::LwPolyline {
vertices: (0..5)
.map(|k| PolyVertex {
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.r(-s, s) * 0.3),
bulge: if k == 1 { 0.5 } else { 0.0 },
start_width: 0.0,
end_width: 0.0,
})
.collect(),
closed: i % 3 == 0,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
5 => EntityKind::Text(Text {
insert: v3(x, y),
align_pt: None,
height: s * 0.3,
value: format!("T{i}"),
rotation: r.r(0.0, 1.0),
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: Default::default(),
valign: Default::default(),
}),
6 => EntityKind::Point(cadcraft_doc::Point { p: v3(x, y), angle: 0.0 }),
7 => EntityKind::Insert(Insert {
block: "B".into(),
insert: v3(x, y),
scale: Vec3::new(s * 0.3, s * 0.3, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
8 if i % 40 == 8 => EntityKind::XLine(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(r.r(-1.0, 1.0), 1.0) }),
8 if i % 40 == 18 => EntityKind::Ray(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(1.0, r.r(-1.0, 1.0)) }),
_ => EntityKind::Circle(cadcraft_doc::Circle { center: v3(x, y), radius: s * 0.1 }),
};
let common = if i % 17 == 0 { Common { layer: "OFF".into(), ..Common::default() } } else { Common::default() };
d.add(&Space::Model, common, kind).unwrap();
}
d
}
fn forced(d: &Drawing) -> Option<Arc<SpatialIndex>> {
Some(Arc::new(SpatialIndex::build(d, &d.model)))
}
#[test]
fn pick_parity_with_scan() {
for seed in [3, 11, 97] {
let d = drawing(seed, 600);
let ix = forced(&d);
let mut r = Rng(seed * 7 + 1);
let mut hits = 0;
for k in 0..400 {
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
let ap = [0.05, 0.3, 1.5][k % 3];
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
hits += usize::from(a.is_some());
}
assert!(hits > 20, "test should exercise hits ({hits})");
}
}
#[test]
fn window_and_fence_parity_with_scan() {
for seed in [5, 23] {
let d = drawing(seed, 500);
let ix = forced(&d);
let mut r = Rng(seed + 100);
for k in 0..60 {
let a = Vec2::new(r.r(-10.0, 110.0), r.r(-10.0, 110.0));
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
let bx = Bounds2::new(a, b);
for crossing in [false, true] {
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
}
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
}
}
}
#[test]
fn osnap_parity_with_scan() {
let d = drawing(41, 500);
let ix = forced(&d);
let mut r = Rng(9);
let modes = [
snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::INT,
snap::mode::QUA | snap::mode::NEA | snap::mode::INS | snap::mode::NOD,
0x3fff,
];
let mut hits = 0;
for k in 0..300 {
let p = Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0));
let base = if k % 2 == 0 { Some(Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))) } else { None };
let m = modes[k % 3];
let a = snap::osnap_with(&d, &Space::Model, &ix, p, 1.0, m, base);
let b = snap::osnap_with(&d, &Space::Model, &None, p, 1.0, m, base);
assert_eq!(a, b, "p {p:?} mode {m}");
hits += usize::from(a.is_some());
}
assert!(hits > 20);
}
#[test]
fn hostile_queries_match_scan() {
let d = drawing(77, 300);
let ix = forced(&d);
for (p, ap) in
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
{
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
}
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
}
#[test]
fn cached_index_follows_edits() {
let mut d = drawing(13, 400);
let first = index(&d, &Space::Model).unwrap();
assert!(Arc::ptr_eq(&first, &index(&d, &Space::Model).unwrap()), "unchanged drawing reuses its index");
// Move a line far away: the cached index must not be reused.
let h = d.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_)) && d.is_visible(e)).unwrap().handle;
d.modify_entity(h, |e| {
if let EntityKind::Line(l) = &mut e.kind {
l.a = v3(500.0, 500.0);
l.b = v3(501.0, 500.0);
}
})
.unwrap();
let second = index(&d, &Space::Model).unwrap();
assert!(!Arc::ptr_eq(&first, &second));
assert_eq!(select::pick(&d, &Space::Model, Vec2::new(500.5, 500.0), 0.1), Some(h));
// A new entity is found too.
let nh = d.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: v3(-300.0, 0.0), radius: 1.0 })).unwrap();
assert_eq!(select::pick(&d, &Space::Model, Vec2::new(-299.0, 0.0), 0.1), Some(nh));
// Redefining the block changes insert bounds: the index is rebuilt.
let before = index(&d, &Space::Model).unwrap();
let mut blk = (**d.blocks.get("B").unwrap()).clone();
blk.entities
.push(Entity::new(cadcraft_doc::Handle(2_000_000), EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 50.0 })));
d.blocks.insert("B".into(), Arc::new(blk));
assert!(!Arc::ptr_eq(&before, &index(&d, &Space::Model).unwrap()));
}
#[test]
fn small_spaces_are_scanned() {
let d = drawing(1, MIN_INDEXED - 1);
assert!(index(&d, &Space::Model).is_none());
assert!(index(&d, &Space::Paper("nope".into())).is_none());
}
}
+1
View File
@@ -10,3 +10,4 @@ workspace = true
[dependencies]
cadcraft-geom = { workspace = true }
skrifa = { workspace = true }
+24
View File
@@ -9,6 +9,7 @@
mod mtext;
mod stroke;
pub mod ttf;
use cadcraft_geom::{Bounds2, Vec2};
@@ -282,3 +283,26 @@ mod tests {
assert!((bb.max.y - 1.0).abs() < 1e-9);
}
}
#[cfg(test)]
mod ttf_tests {
#[test]
fn system_font_outlines_when_available() {
// Skips cleanly when no common system font is installed.
let Some(bytes) = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].iter().find_map(|n| crate::ttf::find(n)) else {
return;
};
let run = crate::ttf::layout_line(&bytes, "CAD", 1.0, 1.0, 0.0).unwrap();
assert!(run.strokes.len() >= 3, "glyph contours");
assert!(run.width > 1.5 && run.width < 4.0, "width {}", run.width);
let maxy = run.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max);
assert!((maxy - 1.0).abs() < 0.1, "cap height ~ text height, got {maxy}");
}
#[test]
fn stroke_names_are_not_ttf() {
assert!(crate::ttf::find("txt.shx").is_none());
assert!(crate::ttf::find("").is_none());
assert!(crate::ttf::layout_line(b"not a font", "x", 1.0, 1.0, 0.0).is_none());
}
}
+202
View File
@@ -0,0 +1,202 @@
//! TrueType/OpenType text: system fonts found at run time (never bundled), glyph outlines as
//! closed polylines in CAD text units (text height = cap height).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use cadcraft_geom::Vec2;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{FontRef, MetadataProvider};
use crate::Run;
/// Font bytes by lower-case family/file stem.
type Db = HashMap<String, Arc<Vec<u8>>>;
fn db() -> &'static Mutex<Option<Db>> {
static DB: OnceLock<Mutex<Option<Db>>> = OnceLock::new();
DB.get_or_init(|| Mutex::new(None))
}
#[cfg(not(target_arch = "wasm32"))]
fn scan() -> Db {
let mut out = Db::new();
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
if let Some(h) = std::env::var_os("HOME") {
dirs.push(std::path::PathBuf::from(h).join("Library/Fonts"));
}
} else if cfg!(windows) {
dirs.push("C:\\Windows\\Fonts".into());
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
if let Some(h) = std::env::var_os("HOME") {
dirs.push(std::path::PathBuf::from(&h).join(".fonts"));
dirs.push(std::path::PathBuf::from(h).join(".local/share/fonts"));
}
}
let mut stack = dirs;
let mut seen = 0usize;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
seen += 1;
if seen > 20_000 {
return out;
}
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let ext = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if ext != "ttf" && ext != "otf" {
continue;
}
if let Some(stem) = p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()) {
out.entry(stem).or_insert_with(|| Arc::new(Vec::new()));
// Load lazily: store the path in a side table by using an empty marker.
PATHS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(), p.clone());
}
}
}
out
}
#[cfg(not(target_arch = "wasm32"))]
static PATHS: OnceLock<Mutex<HashMap<String, std::path::PathBuf>>> = OnceLock::new();
#[cfg(target_arch = "wasm32")]
fn scan() -> Db {
Db::new()
}
/// Register font bytes under a name (web builds and tests).
pub fn register(name: &str, bytes: Vec<u8>) {
let mut g = db().lock().unwrap_or_else(PoisonError::into_inner);
g.get_or_insert_with(Db::new).insert(name.to_ascii_lowercase(), Arc::new(bytes));
}
/// Normalise a style font name ("arial.ttf", "Arial", "ARIALBD.TTF") to a lookup key.
fn key(name: &str) -> String {
let n = name.trim().to_ascii_lowercase();
n.trim_end_matches(".ttf").trim_end_matches(".otf").trim_end_matches(".ttc").to_string()
}
/// Bytes of a font by name, if installed. Stroke-font names (".shx", empty) return `None`.
pub fn find(name: &str) -> Option<Arc<Vec<u8>>> {
let k = key(name);
if k.is_empty() || k.ends_with(".shx") || k == crate::BUILTIN_FONT.to_ascii_lowercase() || k == "txt" || k == "simplex" || k == "romans" {
return None;
}
let mut g = db().lock().unwrap_or_else(PoisonError::into_inner);
let d = g.get_or_insert_with(scan);
let candidates = [k.clone(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))];
for c in &candidates {
if let Some(b) = d.get(c) {
if !b.is_empty() {
return Some(b.clone());
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = PATHS.get().and_then(|m| m.lock().unwrap_or_else(PoisonError::into_inner).get(c).cloned())?;
let bytes = std::fs::read(path).ok()?;
let arc = Arc::new(bytes);
d.insert(c.clone(), arc.clone());
return Some(arc);
}
}
}
None
}
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 flush(&mut self) {
if self.cur.len() > 1 {
if let Some(f) = self.cur.first().copied() {
self.cur.push(f);
}
self.contours.push(std::mem::take(&mut self.cur));
} else {
self.cur.clear();
}
}
}
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);
}
fn line_to(&mut self, x: f32, y: f32) {
let p = self.pt(x, y);
self.cur.push(p);
}
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);
for i in 1..=6 {
let t = f64::from(i) / 6.0;
let u = 1.0 - t;
self.cur.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
}
}
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);
for i in 1..=8 {
let t = f64::from(i) / 8.0;
let u = 1.0 - t;
self.cur.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t));
}
}
fn close(&mut self) {
self.flush();
}
}
/// Lay out one line with a TrueType font: closed glyph contours (baseline at y = 0).
pub fn layout_line(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option<Run> {
let f = FontRef::new(font).ok()?;
let upem = 1000.0f32;
let metrics = f.metrics(Size::new(upem), LocationRef::default());
let cap = metrics.cap_height.filter(|c| *c > 0.0).unwrap_or(metrics.ascent * 0.72).max(1.0);
let scale = height / f64::from(cap);
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
let gm = f.glyph_metrics(Size::new(upem), LocationRef::default());
let charmap = f.charmap();
let outlines = f.outline_glyphs();
let mut pen = Pen { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, wf, shear: oblique.tan().clamp(-10.0, 10.0) };
for (c, _, _) in crate::decode_controls(s) {
let gid = charmap.map(c).unwrap_or_default();
if let Some(g) = outlines.get(gid) {
let _ = g.draw(DrawSettings::unhinted(Size::new(upem), LocationRef::default()), &mut pen);
pen.flush();
}
pen.dx += f64::from(gm.advance_width(gid).unwrap_or(upem * 0.5)) * scale * wf;
}
Some(Run { strokes: pen.contours, width: pen.dx })
}
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cadcraft-io"
description = "CADCraft file formats: DXF read/write mapping, SVG and PNG export"
description = "CADCraft file formats: DXF read/write mapping, SVG, PNG and PDF export"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -16,3 +16,5 @@ cadcraft-dxf = { workspace = true }
cadcraft-dwg = { workspace = true }
cadcraft-render = { workspace = true }
thiserror = { workspace = true }
serde_json = { workspace = true }
miniz_oxide = "0.8"
+32 -4
View File
@@ -674,7 +674,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
d.layouts.clear();
let mut block_records: HashMap<String, String> = HashMap::new();
let mut blocks: Vec<(String, Vec3, Vec<(Option<String>, bool, Entity)>, bool)> = Vec::new();
let mut layout_objs: Vec<(String, u32, String)> = Vec::new();
let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();
let mut entities = Vec::new();
for s in &secs {
match s.name.as_str() {
@@ -712,7 +712,8 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
let t = T(tail);
// LAYOUT has two 330s: owner dict and the block record; take the last.
let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default();
layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase()));
let plot = T(tg.get(..start).unwrap_or(&[]));
layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot)));
}
}
}
@@ -722,11 +723,11 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
// Layouts.
let mut paper_block_for: HashMap<String, String> = HashMap::new();
layout_objs.sort_by_key(|l| l.1);
for (name, order, br) in &layout_objs {
for (name, order, br, page) in &layout_objs {
if name.eq_ignore_ascii_case("Model") {
continue;
}
d.layouts.push(Layout::new(name, *order));
d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) });
if let Some(bname) = block_records.get(br) {
paper_block_for.insert(bname.to_ascii_uppercase(), name.clone());
}
@@ -789,3 +790,30 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
}
Ok(d)
}
/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm).
fn page_setup(t: &T) -> PageSetup {
let mut p = PageSetup::default();
let mm = |code: i32, d: f64| {
let v = t.fd(code, d);
if v.is_finite() && (0.0..=100_000.0).contains(&v) { v } else { d }
};
let (w, h) = (mm(44, 0.0), mm(45, 0.0));
if w > 0.0 && h > 0.0 {
p.width_mm = w.min(h);
p.height_mm = w.max(h);
}
p.margins_mm = [mm(40, p.margins_mm[0]), mm(41, p.margins_mm[1]), mm(42, p.margins_mm[2]), mm(43, p.margins_mm[3])];
// Plot rotation 1 or 3 turns the media a quarter turn; media defined wider than tall is
// already landscape.
let rotated = matches!(t.i(73), Some(1 | 3));
if w > 0.0 && h > 0.0 {
p.landscape = rotated != (w > h);
} else if t.i(73).is_some() {
p.landscape = rotated;
}
if let Some(name) = t.s(4).filter(|n| !n.is_empty()) {
p.paper = name.replace('_', " ");
}
p
}
+3 -1
View File
@@ -535,6 +535,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, dim_bloc
w.i(69, i64::from(v.id));
w.p2(12, v.view_center);
w.f(45, v.view_height);
// Status flags: 16384 = display locked.
w.i(90, if v.locked { 16384 } else { 0 });
}
// Not yet written: images, wipeouts, tables, multileaders, unknown objects.
_ => {}
@@ -918,7 +920,7 @@ pub fn write(d: &Drawing) -> String {
w.s(100, "AcDbPlotSettings");
w.s(1, "");
w.s(2, "none_device");
w.s(4, "");
w.s(4, page.paper.replace(' ', "_"));
w.s(6, "");
for (c, v) in [
(40, page.margins_mm[0]),
+5 -1
View File
@@ -1,13 +1,16 @@
//! CADCraft file formats: DXF read/write, SVG and PNG export.
//! CADCraft file formats: DXF read/write, SVG, PNG and PDF export (plotting).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod dxf_read;
mod dxf_write;
pub mod pdf;
pub mod svg;
use cadcraft_doc::{Drawing, Space};
pub use pdf::{PdfOptions, pdf, plot};
#[derive(Debug, thiserror::Error)]
pub enum IoError {
#[error("{0}")]
@@ -42,6 +45,7 @@ pub fn write(d: &Drawing, name: &str) -> Result<Vec<u8>> {
"dwg" => cadcraft_dwg::dxf_to_dwg(dxf_write::write(d).as_bytes()).map_err(IoError::Format),
"svg" => Ok(svg::export(d, &Space::Model).into_bytes()),
"png" => png(d, &Space::Model, 2400, 1600),
"pdf" => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, ..PdfOptions::default() }),
e => Err(IoError::Unsupported(e.to_string())),
}
}
+370
View File
@@ -0,0 +1,370 @@
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF 1.4 writer of our own.
//!
//! One page per plot: model-space extents fitted to a sheet, or a layout at 1:1 on its paper
//! (paper units are millimetres in metric drawings, inches otherwise). Lines are stroked paths
//! (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white
//! paper, and line widths come from lineweights when enabled.
use std::fmt::Write as _;
use cadcraft_color::{Rgb, display_rgb};
use cadcraft_doc::{Drawing, PageSetup, Space};
use cadcraft_geom::{Bounds2, Vec2};
use cadcraft_render::{Kind, Sheet, clip, paper};
use serde_json::Value;
use crate::{IoError, Result};
/// PDF points per millimetre.
const PT_PER_MM: f64 = 72.0 / 25.4;
/// Largest sheet side we accept, in millimetres (PDF viewers cap pages at 200 inches anyway).
const MAX_SHEET_MM: f64 = 5080.0;
/// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for
/// model space).
#[derive(Clone, Debug, Default)]
pub struct PdfOptions {
/// Paper name from [`cadcraft_render::PAPER_SIZES`] (`"A4"`, `"Letter"`, `"ANSI B"`…).
pub paper: Option<String>,
/// Custom paper size in millimetres (portrait width, height).
pub paper_mm: Option<(f64, f64)>,
pub landscape: Option<bool>,
/// Fit the drawing to the printable area. Default: on for model space, off for layouts (1:1).
pub fit: Option<bool>,
/// Plot scale in paper units per drawing unit (when not fitting).
pub scale: Option<f64>,
/// Plot object lineweights. Default: the layout's setting; on for model space.
pub lineweights: Option<bool>,
/// Flate-compress the content stream.
pub compress: bool,
pub title: String,
}
impl PdfOptions {
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?}`.
pub fn from_json(v: &Value) -> PdfOptions {
let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let paper_mm = match (num("width"), num("height")) {
(Some(w), Some(h)) if w > 0.0 && h > 0.0 => Some((w, h)),
_ => None,
};
PdfOptions {
paper: v.get("paper").and_then(Value::as_str).map(str::to_string),
paper_mm,
landscape: v.get("landscape").and_then(Value::as_bool),
fit: v.get("fit").and_then(Value::as_bool),
scale: num("scale").filter(|s| *s > 0.0),
lineweights: v.get("lineweights").and_then(Value::as_bool),
compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true),
title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
}
}
}
/// Plot with JSON options (the engine's `plot` hook).
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
pdf(d, space, &PdfOptions::from_json(opts))
}
/// The page setup a plot of `space` uses, with overrides applied.
pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<PageSetup> {
let mut page = match space {
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).ok_or_else(|| IoError::Format(format!("no layout `{n}`")))?,
Space::Model => {
let mut p = PageSetup::default();
if paper::paper_unit_mm(d) == 1.0
&& let Some(a4) = paper::paper_size("A4")
{
p.paper = a4.name.into();
p.width_mm = a4.width_mm;
p.height_mm = a4.height_mm;
}
p
}
};
if let Some(name) = &o.paper {
let ps = paper::paper_size(name).ok_or_else(|| IoError::Format(format!("unknown paper size `{name}`")))?;
page.paper = ps.name.into();
page.width_mm = ps.width_mm;
page.height_mm = ps.height_mm;
}
if let Some((w, h)) = o.paper_mm {
page.paper = format!("User ({w:.2} x {h:.2} MM)");
page.width_mm = w.min(MAX_SHEET_MM);
page.height_mm = h.min(MAX_SHEET_MM);
}
if let Some(l) = o.landscape {
page.landscape = l;
}
page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM);
page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM);
Ok(page)
}
/// World → paper-unit mapping: `p' = (p - from) * s + to`.
#[derive(Clone, Copy)]
struct Map {
from: Vec2,
s: f64,
to: Vec2,
/// Points per paper unit.
k: f64,
}
impl Map {
fn pt(&self, p: Vec2) -> Vec2 {
((p - self.from) * self.s + self.to) * self.k
}
}
/// Write a one-page vector PDF of a space.
pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<Vec<u8>> {
let page = page_for(d, space, o)?;
let unit_mm = paper::paper_unit_mm(d);
let sheet = Sheet::from_page(&page, unit_mm);
let lineweights = o.lineweights.unwrap_or(match space {
Space::Paper(_) => page.lineweights,
Space::Model => true,
});
let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights };
let k = unit_mm * PT_PER_MM;
let fit = o.fit.unwrap_or(matches!(space, Space::Model));
// Chord tolerance: about 0.05 mm on paper.
let est = plot_scale(&d.extents(space), &sheet, fit, o.scale);
let tol = 0.05 / unit_mm / est.max(1e-300);
let tolerance = if tol.is_finite() && tol > 0.0 { tol } else { ropts.tolerance };
let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, ..ropts });
let b = list.bounds;
let s = plot_scale(&b, &sheet, fit, o.scale);
let map = if fit || matches!(space, Space::Model) {
let from = if b.is_empty() { Vec2::ZERO } else { b.center() };
Map { from, s, to: sheet.printable.center(), k }
} else {
Map { from: Vec2::ZERO, s: 1.0, to: Vec2::ZERO, k }
};
let media = sheet.size * k;
let clip_pt = if fit || matches!(space, Space::Model) {
Bounds2::new(sheet.printable.min * k, sheet.printable.max * k)
} else {
Bounds2::new(Vec2::ZERO, media)
};
let content = content_stream(&list, &map, &clip_pt);
let title = if o.title.is_empty() {
match space {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
}
} else {
o.title.clone()
};
Ok(assemble(media, content.as_bytes(), o.compress, &title))
}
/// Paper units per drawing unit for a plot.
fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64 {
if let Some(s) = scale.filter(|s| s.is_finite() && *s > 0.0)
&& !fit
{
return s;
}
if !fit || b.is_empty() {
return 1.0;
}
let pw = sheet.printable.width();
let ph = sheet.printable.height();
let s = (pw / b.width().max(1e-12)).min(ph / b.height().max(1e-12));
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
/// A number for a content stream: short, finite, no exponent.
fn num(out: &mut String, v: f64) {
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
let mut s = format!("{v:.3}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if s == "-0" {
s = "0".into();
}
out.push_str(&s);
}
fn pt(out: &mut String, p: Vec2) {
num(out, p.x);
out.push(' ');
num(out, p.y);
}
fn color(out: &mut String, c: Rgb, op: &str) {
for v in [c.0, c.1, c.2] {
num(out, f64::from(v) / 255.0);
out.push(' ');
}
out.push_str(op);
out.push('\n');
}
fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2) -> String {
let white = Rgb(255, 255, 255);
let mut c = String::new();
c.push_str("q\n1 J 1 j\n");
// Clip to the plot area.
pt(&mut c, clip_pt.min);
c.push(' ');
num(&mut c, clip_pt.width());
c.push(' ');
num(&mut c, clip_pt.height());
c.push_str(" re W n\n");
let mut stroke: Option<Rgb> = None;
let mut fill: Option<Rgb> = None;
let mut width: Option<f64> = None;
let mut pending = false;
let flush = |c: &mut String, pending: &mut bool| {
if *pending {
c.push_str("S\n");
*pending = false;
}
};
for p in &list.prims {
let rgb = display_rgb(p.color, white);
let raw = list.points(p);
if raw.iter().any(|q| !q.is_finite()) {
continue;
}
let w = f64::from(p.lw) * PT_PER_MM;
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
match p.kind {
Kind::Polyline | Kind::Infinite { .. } => {
let pts: Vec<Vec2> = match p.kind {
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
(Some(base), Some(dir)) => {
let a = map.pt(*base);
let dir_pt = map.pt(*base + *dir) - a;
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
Some((x, y)) => vec![x, y],
None => continue,
}
}
_ => continue,
},
_ => raw.iter().map(|q| map.pt(*q)).collect(),
};
if pts.len() < 2 {
continue;
}
if stroke != Some(rgb) || width != Some(w) {
flush(&mut c, &mut pending);
if stroke != Some(rgb) {
color(&mut c, rgb, "RG");
stroke = Some(rgb);
}
if width != Some(w) {
num(&mut c, w);
c.push_str(" w\n");
width = Some(w);
}
}
for (i, q) in pts.iter().enumerate() {
pt(&mut c, *q);
c.push_str(if i == 0 { " m\n" } else { " l\n" });
}
pending = true;
}
Kind::Tris => {
flush(&mut c, &mut pending);
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
let mut any = false;
for t in raw.as_chunks::<3>().0 {
for (i, q) in t.iter().enumerate() {
pt(&mut c, map.pt(*q));
c.push_str(if i == 0 { " m\n" } else { " l\n" });
}
c.push_str("h\n");
any = true;
}
if any {
c.push_str("f\n");
}
}
Kind::Point => {
let Some(q) = raw.first() else { continue };
flush(&mut c, &mut pending);
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
let half = (w.max(0.5)) / 2.0;
pt(&mut c, map.pt(*q) - Vec2::new(half, half));
c.push(' ');
num(&mut c, half * 2.0);
c.push(' ');
num(&mut c, half * 2.0);
c.push_str(" re f\n");
}
}
}
flush(&mut c, &mut pending);
c.push_str("Q\n");
c
}
/// A PDF literal string with `(`, `)` and `\` escaped; non-ASCII becomes `?`.
fn pdf_string(s: &str) -> String {
let mut out = String::from("(");
for ch in s.chars().take(200) {
match ch {
'(' | ')' | '\\' => {
out.push('\\');
out.push(ch);
}
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
_ => out.push('?'),
}
}
out.push(')');
out
}
/// Assemble the file: header, five objects, cross-reference table and trailer.
fn assemble(media: Vec2, content: &[u8], compress: bool, title: &str) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(content.len() + 1024);
out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
let mut offsets: Vec<usize> = Vec::new();
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
offsets.push(out.len());
let n = offsets.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
let mut mb = String::new();
num(&mut mb, media.x);
mb.push(' ');
num(&mut mb, media.y);
obj(&mut out, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {mb}] /Resources << >> /Contents 4 0 R >>").as_bytes());
let (data, filter) =
if compress { (miniz_oxide::deflate::compress_to_vec_zlib(content, 6), " /Filter /FlateDecode") } else { (content.to_vec(), "") };
let mut stream = format!("<< /Length {}{filter} >>\nstream\n", data.len()).into_bytes();
stream.extend_from_slice(&data);
stream.extend_from_slice(b"\nendstream");
obj(&mut out, &stream);
obj(&mut out, format!("<< /Producer (CADCraft) /Creator (CADCraft) /Title {} >>", pdf_string(title)).as_bytes());
let xref = out.len();
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1);
for o in &offsets {
let _ = writeln!(x, "{o:010} 00000 n ");
}
let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 5 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1);
out.extend_from_slice(x.as_bytes());
out
}
+139
View File
@@ -219,3 +219,142 @@ fn svg_and_png_export() {
let png = write(&d, "a.png").unwrap();
assert_eq!(&png[1..4], b"PNG");
}
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
fn check_pdf(bytes: &[u8]) -> String {
assert!(bytes.starts_with(b"%PDF-1.4"));
let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat);
let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat);
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
assert!(tail.trim_end().ends_with("%%EOF"));
let sx = rfind(b"startxref\n").unwrap();
let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string();
let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap();
assert!(bytes[xref_off..].starts_with(b"xref\n"));
let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string();
let mut lines = xref.lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count();
assert_eq!(count, objs + 1, "xref size = objects + free entry");
assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs);
let entries: Vec<&str> = lines.take(count).collect();
assert!(entries[0].starts_with("0000000000 65535 f"));
for (i, e) in entries.iter().enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset");
}
assert!(xref.contains(&format!("/Size {count}")));
// The content stream's /Length matches its data.
let sp = find(b"/Length ").unwrap();
let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string();
let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = find(b"stream\n").unwrap() + 7;
assert!(bytes[start + len..].starts_with(b"\nendstream"));
let compressed = find(b"/FlateDecode").is_some();
let data = &bytes[start..start + len];
if compressed {
String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap()
} else {
String::from_utf8(data.to_vec()).unwrap()
}
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
let text = String::from_utf8_lossy(bytes).to_string();
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
(v[0], v[1])
}
#[test]
fn pdf_model_fitted_to_sheet() {
let d = sample();
let bytes = plot(&d, &Space::Model, &serde_json::json!({"paper": "A4", "landscape": true})).unwrap();
let content = check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
// Walls layer is red; colour 7 prints black.
assert!(content.contains("1 0 0 RG"));
assert!(content.contains("0 0 0 RG"));
// Uncompressed output is plain text and also valid.
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
let c2 = check_pdf(&raw);
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(c2.contains("re W n"));
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
#[test]
fn pdf_layout_one_to_one_with_viewport() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
d.layouts[0].page.lineweights = true;
d.add(
&paper,
Common::default(),
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id: 2,
locked: false,
frozen_layers: Vec::new(),
}),
)
.unwrap();
let bytes = plot(&d, &paper, &serde_json::json!({})).unwrap();
let content = check_pdf(&bytes);
// ANSI A landscape (inches → points).
assert_eq!(media_box(&bytes), (792.0, 612.0));
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(content.contains("0.709 w"));
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(check_pdf(&off).contains("0 w"));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
#[test]
fn pdf_hostile_options_and_empty() {
let d = Drawing::new_metric();
let bytes = plot(&d, &Space::Model, &serde_json::json!({})).unwrap();
check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "metric default is A4 landscape");
let s = sample();
for o in [
serde_json::json!({"width": 1e308, "height": -5}),
serde_json::json!({"width": 1e308, "height": 1e308, "scale": 1e308, "fit": false}),
serde_json::json!({"scale": -1, "fit": false, "title": "a(b)\\c\u{e9}"}),
serde_json::json!(null),
serde_json::json!([1, 2]),
] {
check_pdf(&plot(&s, &Space::Model, &o).unwrap());
}
}
#[test]
fn dxf_roundtrips_page_setup() {
let mut d = sample();
let a3 = cadcraft_render::paper_size("A3").unwrap();
{
let p = &mut d.layouts[0].page;
p.paper = a3.name.into();
p.width_mm = a3.width_mm;
p.height_mm = a3.height_mm;
p.landscape = false;
p.margins_mm = [5.0, 6.0, 7.0, 8.0];
}
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
let p = &back.layout("Layout1").unwrap().page;
assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, false));
assert_eq!(p.margins_mm, [5.0, 6.0, 7.0, 8.0]);
assert_eq!(p.paper, a3.name);
}
+197
View File
@@ -0,0 +1,197 @@
//! Clipping against axis-aligned rectangles (viewport contents, plot sheets).
use cadcraft_geom::{Bounds2, Vec2};
/// Clip the segment `a`→`b` to `r` (Liang–Barsky). Returns the parameter range kept.
fn clip_params(a: Vec2, d: Vec2, r: &Bounds2, mut t0: f64, mut t1: f64) -> Option<(f64, f64)> {
for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let t = q / p;
if p < 0.0 {
if t > t1 {
return None;
}
if t > t0 {
t0 = t;
}
} else {
if t < t0 {
return None;
}
if t < t1 {
t1 = t;
}
}
}
}
(t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some((t0, t1))
}
/// Clip a segment to `r`.
pub fn clip_segment(a: Vec2, b: Vec2, r: &Bounds2) -> Option<(Vec2, Vec2)> {
if r.is_empty() || !a.is_finite() || !b.is_finite() {
return None;
}
let d = b - a;
let (t0, t1) = clip_params(a, d, r, 0.0, 1.0)?;
Some((a + d * t0, a + d * t1))
}
/// Clip an infinite line (or a ray when `ray`) through `base` along `dir` to `r`.
pub fn clip_infinite(base: Vec2, dir: Vec2, ray: bool, r: &Bounds2) -> Option<(Vec2, Vec2)> {
if r.is_empty() || !base.is_finite() || !dir.is_finite() || dir == Vec2::ZERO {
return None;
}
let far = (r.width() + r.height() + (base - r.center()).len()) * 2.0 / dir.len().max(1e-300);
let (t0, t1) = clip_params(base, dir, r, if ray { 0.0 } else { -far }, far)?;
Some((base + dir * t0, base + dir * t1))
}
/// Clip a polyline to `r`; returns the visible pieces (each with at least two points).
pub fn clip_polyline(pts: &[Vec2], r: &Bounds2) -> Vec<Vec<Vec2>> {
let mut out: Vec<Vec<Vec2>> = Vec::new();
if r.is_empty() {
return out;
}
// Fast path: everything inside.
if pts.iter().all(|p| r.contains(*p)) {
if pts.len() >= 2 {
out.push(pts.to_vec());
}
return out;
}
let mut cur: Vec<Vec2> = Vec::new();
for w in pts.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
match clip_segment(*a, *b, r) {
Some((p, q)) => {
let joined = cur.last().is_some_and(|l| l.near(p, 1e-12));
if !joined {
if cur.len() >= 2 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(p);
}
cur.push(q);
// Leaving the rectangle ends the piece.
if !q.near(*b, 1e-12) {
if cur.len() >= 2 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
}
}
None => {
if cur.len() >= 2 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
}
}
}
if cur.len() >= 2 {
out.push(cur);
}
out
}
/// Clip a convex polygon to `r` (Sutherland–Hodgman).
pub fn clip_polygon(poly: &[Vec2], r: &Bounds2) -> Vec<Vec2> {
if r.is_empty() {
return Vec::new();
}
let mut cur: Vec<Vec2> = poly.to_vec();
// Each edge: inside test and intersection with the boundary line.
let edges: [(u8, f64); 4] = [(0, r.min.x), (1, r.max.x), (2, r.min.y), (3, r.max.y)];
for (side, v) in edges {
let inside = |p: &Vec2| match side {
0 => p.x >= v,
1 => p.x <= v,
2 => p.y >= v,
_ => p.y <= v,
};
let cross = |a: Vec2, b: Vec2| -> Vec2 {
let t = match side {
0 | 1 => (v - a.x) / (b.x - a.x),
_ => (v - a.y) / (b.y - a.y),
};
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
a + (b - a) * t
};
let mut next = Vec::with_capacity(cur.len() + 2);
let n = cur.len();
for i in 0..n {
let (Some(a), Some(b)) = (cur.get(i).copied(), cur.get((i + 1) % n).copied()) else { continue };
match (inside(&a), inside(&b)) {
(true, true) => next.push(b),
(true, false) => next.push(cross(a, b)),
(false, true) => {
next.push(cross(a, b));
next.push(b);
}
(false, false) => {}
}
}
cur = next;
if cur.is_empty() {
break;
}
}
cur
}
/// Clip a triangle list (three vertices per triangle) to `r`, re-triangulating as fans.
pub fn clip_triangles(tris: &[Vec2], r: &Bounds2) -> Vec<Vec2> {
let mut out = Vec::new();
for t in tris.as_chunks::<3>().0 {
if t.iter().all(|p| r.contains(*p)) {
out.extend_from_slice(t);
continue;
}
let poly = clip_polygon(t, r);
if let Some(first) = poly.first().copied() {
for w in poly.get(1..).unwrap_or(&[]).windows(2) {
if let (Some(b), Some(c)) = (w.first(), w.get(1)) {
out.extend_from_slice(&[first, *b, *c]);
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn r() -> Bounds2 {
Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0))
}
#[test]
fn segments_and_polylines() {
let (a, b) = clip_segment(Vec2::new(-5.0, 5.0), Vec2::new(15.0, 5.0), &r()).unwrap();
assert!(a.near(Vec2::new(0.0, 5.0), 1e-9) && b.near(Vec2::new(10.0, 5.0), 1e-9));
assert!(clip_segment(Vec2::new(-5.0, -5.0), Vec2::new(-1.0, 20.0), &r()).is_none());
// In, out, back in: two pieces.
let pieces = clip_polyline(&[Vec2::new(5.0, 5.0), Vec2::new(15.0, 5.0), Vec2::new(15.0, 8.0), Vec2::new(5.0, 8.0)], &r());
assert_eq!(pieces.len(), 2);
let (a, b) = clip_infinite(Vec2::new(5.0, 5.0), Vec2::new(1.0, 0.0), false, &r()).unwrap();
assert!(a.near(Vec2::new(0.0, 5.0), 1e-9) && b.near(Vec2::new(10.0, 5.0), 1e-9));
let (a, _) = clip_infinite(Vec2::new(5.0, 5.0), Vec2::new(1.0, 0.0), true, &r()).unwrap();
assert!(a.near(Vec2::new(5.0, 5.0), 1e-9));
assert!(clip_segment(Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0), &r()).is_none());
}
#[test]
fn triangles() {
let t = clip_triangles(&[Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(5.0, 10.0)], &r());
assert!(!t.is_empty() && t.len().is_multiple_of(3));
assert!(t.iter().all(|p| p.x >= -1e-9));
}
}
+157 -28
View File
@@ -7,10 +7,12 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod clip;
mod dim;
mod fill;
mod hatch;
mod linetype;
pub mod paper;
pub mod raster;
use cadcraft_color::{Color, Rgb};
@@ -19,6 +21,11 @@ use cadcraft_geom::{Bounds2, Mat3, Polyline, Vec2};
pub use dim::dimension_geometry;
pub use fill::triangulate_evenodd;
pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
/// Handle carried by model-space geometry drawn inside a paper-space viewport (it belongs to no
/// single paper-space entity, so selection highlighting never matches it).
pub const VIEWPORT_CONTENT: Handle = Handle(0);
/// What a display-list primitive draws.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -50,6 +57,9 @@ pub struct DisplayList {
pub verts: Vec<Vec2>,
pub tris: Vec<Vec2>,
pub bounds: Bounds2,
/// The paper sheet when the list was built for a layout (paper space); the UI draws the
/// white sheet, its shadow and the printable-area outline from it.
pub sheet: Option<Sheet>,
}
impl DisplayList {
@@ -96,11 +106,15 @@ struct Ctx<'a> {
block_ltype: String,
depth: usize,
top: Handle,
/// Layers frozen in the viewport being drawn.
frozen: &'a [String],
}
struct Builder<'a> {
list: DisplayList,
opts: &'a Options,
/// Plotting: skip layers marked "do not plot".
plotting: bool,
}
impl Builder<'_> {
@@ -143,52 +157,166 @@ impl Builder<'_> {
}
}
/// Build the display list for a space.
/// Build the display list for a space. For a layout (paper space) this includes the model-space
/// geometry seen through each viewport, clipped to the viewport, and [`DisplayList::sheet`].
pub fn build(d: &Drawing, space: &Space, opts: &Options) -> DisplayList {
let mut b = Builder { list: DisplayList::default(), opts };
build_space(d, space, opts, false)
}
/// Like [`build`] but for plotting: layers marked "do not plot" (such as `Defpoints`) are left out.
pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList {
build_space(d, space, opts, true)
}
fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String]) -> Ctx<'a> {
Ctx {
d,
xf,
block_color: Color::Index(7),
block_layer: None,
block_lw: Lineweight::Default,
block_ltype: "Continuous".into(),
depth: 0,
top,
frozen,
}
}
fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList {
let mut b = Builder { list: DisplayList::default(), opts, plotting };
if let Space::Paper(name) = space {
b.list.sheet = paper::sheet(d, name);
}
if let Some(store) = d.space(space) {
let paper = matches!(space, Space::Paper(_));
let mut viewports = 0usize;
for e in store.iter() {
let ctx = Ctx {
d,
xf: Mat3::IDENTITY,
block_color: Color::Index(7),
block_layer: None,
block_lw: Lineweight::Default,
block_ltype: "Continuous".into(),
depth: 0,
top: e.handle,
};
entity(&mut b, &ctx, e);
if paper && let EntityKind::Viewport(vp) = &e.kind {
if viewports < MAX_VIEWPORTS {
viewports += 1;
viewport(&mut b, d, e, vp);
}
continue;
}
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e);
}
}
b.list
}
/// Upper bound on viewports drawn per layout (hostile files).
const MAX_VIEWPORTS: usize = 256;
/// A paper-space viewport: its border (as an ordinary entity) and model space seen through it.
/// Viewport id 1 is the paper-space view itself and is not drawn.
fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) {
if vp.id == 1 || !e.common.visible {
return;
}
// Border (on the viewport's layer; layer off hides only the border).
entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e);
let center = vp.center.xy();
let ok = |v: f64| v.is_finite() && v > 0.0;
if !(ok(vp.width) && ok(vp.height) && ok(vp.view_height) && center.is_finite() && vp.view_center.is_finite()) {
return;
}
let s = vp.height / vp.view_height;
if !ok(s) {
return;
}
let half = Vec2::new(vp.width / 2.0, vp.height / 2.0);
let rect = Bounds2::new(center - half, center + half);
let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center));
let mhalf = half / s;
// Model window with slack: entity bounds are approximate (text, dimensions).
let win = Bounds2::new(vp.view_center - mhalf, vp.view_center + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting };
for me in d.model.iter() {
if !matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_) | EntityKind::Viewport(_)) {
let eb = cadcraft_doc::entity_bounds(d, me, 0);
if !eb.is_empty() && !eb.intersects(&win) {
continue;
}
}
if matches!(me.kind, EntityKind::Viewport(_)) {
continue;
}
entity(&mut sub, &top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers), me);
}
append_clipped(&mut b.list, &sub.list, &rect);
}
/// Append `src` to `dst`, clipped to `rect`.
fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) {
for p in &src.prims {
let pts = src.points(p);
match p.kind {
Kind::Polyline => {
for piece in clip::clip_polyline(pts, rect) {
push_raw(dst, p, Kind::Polyline, &piece);
}
}
Kind::Tris => {
let t = clip::clip_triangles(pts, rect);
push_raw(dst, p, Kind::Tris, &t);
}
Kind::Point => {
if let Some(q) = pts.first()
&& rect.contains(*q)
{
push_raw(dst, p, Kind::Point, &[*q]);
}
}
Kind::Infinite { ray } => {
if let (Some(base), Some(dir)) = (pts.first(), pts.get(1))
&& let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect)
{
push_raw(dst, p, Kind::Polyline, &[a, c]);
}
}
}
}
}
fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) {
let min = match kind {
Kind::Polyline => 2,
Kind::Tris => 3,
_ => 1,
};
if pts.len() < min {
return;
}
let tris = kind == Kind::Tris;
let start = if tris { dst.tris.len() } else { dst.verts.len() } as u32;
for q in pts {
dst.bounds.add(*q);
}
if tris {
dst.tris.extend_from_slice(pts);
} else {
dst.verts.extend_from_slice(pts);
}
dst.prims.push(DPrim { handle: p.handle, color: p.color, lw: p.lw, kind, start, len: pts.len() as u32 });
}
/// Build the display list for a set of loose entities (previews, rubber bands).
pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents: I, opts: &Options) -> DisplayList {
let mut b = Builder { list: DisplayList::default(), opts };
let mut b = Builder { list: DisplayList::default(), opts, plotting: false };
for e in ents {
let ctx = Ctx {
d,
xf: Mat3::IDENTITY,
block_color: Color::Index(7),
block_layer: None,
block_lw: Lineweight::Default,
block_ltype: "Continuous".into(),
depth: 0,
top: e.handle,
};
entity(&mut b, &ctx, e);
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e);
}
b.list
}
fn resolve(ctx: &Ctx, e: &Entity) -> (Rgb, f32, Option<cadcraft_doc::Linetype>, f64, bool) {
fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option<cadcraft_doc::Linetype>, f64, bool) {
let d = ctx.d;
// Layer "0" inside a block takes the insert's layer.
let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
let layer = d.layer(layer_name);
let visible = e.common.visible && layer.is_none_or(|l| l.visible());
let visible = e.common.visible
&& layer.is_none_or(|l| l.visible() && (!plotting || l.plot))
&& !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name));
let layer_color = layer.map(|l| l.color).unwrap_or(Color::Index(7));
let rgb = e.common.color.resolve(layer_color, ctx.block_color);
let lw = match e.common.lineweight {
@@ -211,7 +339,7 @@ fn resolve(ctx: &Ctx, e: &Entity) -> (Rgb, f32, Option<cadcraft_doc::Linetype>,
}
fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e);
let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting);
if !visible {
return;
}
@@ -511,6 +639,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
block_ltype: lt,
depth: ctx.depth + 1,
top: ctx.top,
frozen: ctx.frozen,
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Paper sizes and the layout sheet (paper rectangle, printable area) for paper space.
//!
//! Paper-space units are millimetres in metric drawings (`MEASUREMENT` = 1) and inches
//! otherwise. The sheet's lower-left corner is the paper-space origin.
use cadcraft_doc::{Drawing, PageSetup};
use cadcraft_geom::{Bounds2, Vec2};
/// A standard paper size (portrait: `width_mm <= height_mm`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PaperSize {
/// Canonical media name, as shown in page setup.
pub name: &'static str,
/// Short names accepted by commands (`A4`, `Letter`, `ANSI B`…).
pub aliases: &'static [&'static str],
pub width_mm: f64,
pub height_mm: f64,
/// Inch-based (ANSI/ARCH) rather than ISO.
pub imperial: bool,
}
const fn iso(name: &'static str, aliases: &'static [&'static str], w: f64, h: f64) -> PaperSize {
PaperSize { name, aliases, width_mm: w, height_mm: h, imperial: false }
}
const fn inch(name: &'static str, aliases: &'static [&'static str], w: f64, h: f64) -> PaperSize {
PaperSize { name, aliases, width_mm: w * 25.4, height_mm: h * 25.4, imperial: true }
}
/// Common ISO and ANSI/ARCH sheet sizes.
pub const PAPER_SIZES: &[PaperSize] = &[
iso("ISO A0 (841.00 x 1189.00 MM)", &["A0", "ISO A0"], 841.0, 1189.0),
iso("ISO A1 (594.00 x 841.00 MM)", &["A1", "ISO A1"], 594.0, 841.0),
iso("ISO A2 (420.00 x 594.00 MM)", &["A2", "ISO A2"], 420.0, 594.0),
iso("ISO A3 (297.00 x 420.00 MM)", &["A3", "ISO A3"], 297.0, 420.0),
iso("ISO A4 (210.00 x 297.00 MM)", &["A4", "ISO A4"], 210.0, 297.0),
iso("ISO A5 (148.00 x 210.00 MM)", &["A5", "ISO A5"], 148.0, 210.0),
iso("ISO B4 (250.00 x 353.00 MM)", &["B4", "ISO B4"], 250.0, 353.0),
iso("ISO B5 (176.00 x 250.00 MM)", &["B5", "ISO B5"], 176.0, 250.0),
inch("ANSI A (8.50 x 11.00 Inches)", &["ANSI A", "Letter", "A-size"], 8.5, 11.0),
inch("Legal (8.50 x 14.00 Inches)", &["Legal"], 8.5, 14.0),
inch("ANSI B (11.00 x 17.00 Inches)", &["ANSI B", "Tabloid", "Ledger", "B-size"], 11.0, 17.0),
inch("ANSI C (17.00 x 22.00 Inches)", &["ANSI C", "C-size"], 17.0, 22.0),
inch("ANSI D (22.00 x 34.00 Inches)", &["ANSI D", "D-size"], 22.0, 34.0),
inch("ANSI E (34.00 x 44.00 Inches)", &["ANSI E", "E-size"], 34.0, 44.0),
inch("ARCH A (9.00 x 12.00 Inches)", &["ARCH A"], 9.0, 12.0),
inch("ARCH B (12.00 x 18.00 Inches)", &["ARCH B"], 12.0, 18.0),
inch("ARCH C (18.00 x 24.00 Inches)", &["ARCH C"], 18.0, 24.0),
inch("ARCH D (24.00 x 36.00 Inches)", &["ARCH D"], 24.0, 36.0),
inch("ARCH E1 (30.00 x 42.00 Inches)", &["ARCH E1"], 30.0, 42.0),
inch("ARCH E (36.00 x 48.00 Inches)", &["ARCH E"], 36.0, 48.0),
];
/// Look up a paper size by canonical name or alias (case- and space-insensitive).
pub fn paper_size(name: &str) -> Option<&'static PaperSize> {
let norm = |s: &str| s.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect::<String>().to_ascii_lowercase();
let n = norm(name);
if n.is_empty() {
return None;
}
PAPER_SIZES.iter().find(|p| norm(p.name) == n || p.aliases.iter().any(|a| norm(a) == n))
}
/// Millimetres per paper-space unit for a drawing (1 for metric, 25.4 for imperial).
pub fn paper_unit_mm(d: &Drawing) -> f64 {
if d.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 25.4 }
}
/// The paper sheet of a layout, in paper-space units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sheet {
/// Sheet size (already rotated for landscape). The sheet spans `(0,0)..size`.
pub size: Vec2,
/// Printable area (inside the margins).
pub printable: Bounds2,
/// Millimetres per paper-space unit.
pub unit_mm: f64,
}
impl Sheet {
pub fn bounds(&self) -> Bounds2 {
Bounds2::new(Vec2::ZERO, self.size)
}
/// The sheet for a page setup in a drawing whose paper units are `unit_mm` millimetres.
pub fn from_page(page: &PageSetup, unit_mm: f64) -> Sheet {
let sane = |v: f64, d: f64| if v.is_finite() && v > 0.0 { v.min(100_000.0) } else { d };
let w = sane(page.width_mm, 215.9);
let h = sane(page.height_mm, 279.4);
let (w, h) = if page.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) };
let u = if unit_mm.is_finite() && unit_mm > 0.0 { unit_mm } else { 1.0 };
let size = Vec2::new(w / u, h / u);
let m = |i: usize| page.margins_mm.get(i).copied().filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0) / u;
// Margins: left, bottom, right, top (DXF group codes 40..43).
let (l, b, r, t) = (m(0), m(1), m(2), m(3));
let printable = if l + r < size.x && b + t < size.y {
Bounds2::new(Vec2::new(l, b), Vec2::new(size.x - r, size.y - t))
} else {
Bounds2::new(Vec2::ZERO, size)
};
Sheet { size, printable, unit_mm: u }
}
}
/// The sheet of a named layout (`None` if there is no such layout).
pub fn sheet(d: &Drawing, layout: &str) -> Option<Sheet> {
let l = d.layouts.iter().find(|l| l.name == layout).or_else(|| d.layout(layout))?;
Some(Sheet::from_page(&l.page, paper_unit_mm(d)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_and_sheet() {
assert_eq!(paper_size("a4").map(|p| p.width_mm), Some(210.0));
assert_eq!(paper_size("ansi b").map(|p| p.height_mm), Some(17.0 * 25.4));
assert_eq!(paper_size("Letter").map(|p| p.name), Some("ANSI A (8.50 x 11.00 Inches)"));
assert!(paper_size("").is_none());
assert!(paper_size("nope").is_none());
let page = PageSetup { width_mm: 210.0, height_mm: 297.0, margins_mm: [5.0; 4], landscape: true, ..PageSetup::default() };
let s = Sheet::from_page(&page, 1.0);
assert_eq!(s.size, Vec2::new(297.0, 210.0));
assert_eq!(s.printable, Bounds2::new(Vec2::new(5.0, 5.0), Vec2::new(292.0, 205.0)));
let hostile = PageSetup { width_mm: f64::NAN, height_mm: -1.0, margins_mm: [1e9; 4], ..PageSetup::default() };
let s = Sheet::from_page(&hostile, 0.0);
assert!(s.size.x > 0.0 && s.printable == s.bounds());
}
}
+68
View File
@@ -115,3 +115,71 @@ fn raster_rejects_bad_size() {
let l = DisplayList::default();
assert!(render(&l, &View { center: Vec2::ZERO, scale: 1.0, width: 0, height: 10 }, &RasterOptions::default()).is_none());
}
fn layout_with_viewport(vp: cadcraft_doc::Viewport) -> Drawing {
let mut d = sample(); // line (0,0)-(10,0) and red circle at (5,5) r3
let paper = Space::Paper("Layout1".into());
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
d
}
fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32) -> cadcraft_doc::Viewport {
cadcraft_doc::Viewport { center: center.to3(0.0), width: w, height: h, view_center, view_height, id, locked: false, frozen_layers: Vec::new() }
}
#[test]
fn layout_viewport_shows_model_clipped() {
// Viewport 4x4 paper units centred at (5,4), showing model window centred at (5,5), 8 high:
// scale 0.5, so model (5,5) → paper (5,4); the visible model window is (1,1)..(9,9).
let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 4.0, 4.0, Vec2::new(5.0, 5.0), 8.0, 2));
let l = build(&d, &Space::Paper("Layout1".into()), &Options { tolerance: 0.01, ..Default::default() });
let sheet = l.sheet.unwrap();
assert!(sheet.size.x > sheet.size.y, "landscape sheet");
let rect = Bounds2::new(Vec2::new(3.0, 2.0), Vec2::new(7.0, 6.0)).expand(1e-9);
let content: Vec<&DPrim> = l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).collect();
assert!(!content.is_empty(), "model geometry is drawn in the viewport");
for p in &content {
for q in l.points(p) {
assert!(rect.contains(*q), "{q:?} outside the viewport");
}
}
// The circle (red) is visible, scaled by 0.5 about the view centre: radius 1.5 around (5,4).
let red: Vec<Vec2> = content.iter().filter(|p| p.color == Rgb(255, 0, 0)).flat_map(|p| l.points(p).to_vec()).collect();
assert!(!red.is_empty());
assert!(red.iter().all(|q| (q.dist(Vec2::new(5.0, 4.0)) - 1.5).abs() < 0.02));
// The line y=0 maps to paper y=1.5, below the viewport: clipped away entirely.
assert!(content.iter().filter(|p| p.color != Rgb(255, 0, 0)).all(|p| l.points(p).iter().all(|q| q.y > 1.9)));
// The border is drawn with the viewport's own handle.
assert!(l.prims.iter().any(|p| p.handle != VIEWPORT_CONTENT));
}
#[test]
fn layout_viewport_frozen_layer_and_id1() {
let mut v = vp(Vec2::new(5.0, 4.0), 20.0, 20.0, Vec2::new(5.0, 5.0), 20.0, 2);
v.frozen_layers = vec!["0".into()];
let d = layout_with_viewport(v);
let l = build(&d, &Space::Paper("Layout1".into()), &Options::default());
assert!(l.prims.iter().all(|p| p.handle != VIEWPORT_CONTENT), "layer 0 is frozen in this viewport");
// Viewport id 1 (the paper-space view) draws nothing.
let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 20.0, 20.0, Vec2::new(5.0, 5.0), 20.0, 1));
assert!(build(&d, &Space::Paper("Layout1".into()), &Options::default()).prims.is_empty());
// Model space never draws viewport contents; plotting skips non-plot layers.
let mut d = sample();
d.layer_mut("0").unwrap().plot = false;
assert!(build_plot(&d, &Space::Model, &Options::default()).prims.is_empty());
assert!(!build(&d, &Space::Model, &Options::default()).prims.is_empty());
}
#[test]
fn hostile_viewports_do_not_panic() {
for v in [
vp(Vec2::new(f64::NAN, 0.0), 1.0, 1.0, Vec2::ZERO, 1.0, 2),
vp(Vec2::ZERO, -1.0, 1.0, Vec2::ZERO, 1.0, 2),
vp(Vec2::ZERO, 1.0, 1.0, Vec2::ZERO, 0.0, 2),
vp(Vec2::ZERO, 1e308, 1e308, Vec2::new(1e308, -1e308), 1e-308, 2),
vp(Vec2::ZERO, 1.0, 1.0, Vec2::new(f64::INFINITY, 0.0), 1.0, 2),
] {
let d = layout_with_viewport(v);
let _ = build(&d, &Space::Paper("Layout1".into()), &Options::default());
}
}
+6
View File
@@ -15,7 +15,13 @@ cadcraft-doc = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-engine = { workspace = true }
egui = { workspace = true }
# GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature).
egui-wgpu = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
[dev-dependencies]
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
+18
View File
@@ -0,0 +1,18 @@
//! Compile the contributor credits (contributors/*.tsv, craftrules standards/contributors.md)
//! into the About window. Missing or unreadable files give empty tables and a warning.
fn main() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default());
for f in ["commits.tsv", "prs.tsv", "models.tsv", "people.tsv"] {
let p = root.join(f);
println!("cargo::rerun-if-changed={}", p.display());
let text = std::fs::read_to_string(&p).unwrap_or_else(|e| {
println!("cargo::warning=contributors/{f}: {e}; the About window shows an empty table");
String::new()
});
if let Err(e) = std::fs::write(out.join(f), text) {
println!("cargo::warning=writing {f}: {e}");
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! The About window's credits: contributors (GitHub logins; names only with recorded consent)
//! and AI models, from the TSVs compiled in by build.rs.
use std::collections::BTreeMap;
const COMMITS: &str = include_str!(concat!(env!("OUT_DIR"), "/commits.tsv"));
const PRS: &str = include_str!(concat!(env!("OUT_DIR"), "/prs.tsv"));
const MODELS: &str = include_str!(concat!(env!("OUT_DIR"), "/models.tsv"));
const PEOPLE: &str = include_str!(concat!(env!("OUT_DIR"), "/people.tsv"));
fn rows(t: &str) -> Vec<Vec<&str>> {
t.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')).skip(1).map(|l| l.split('\t').collect()).collect()
}
#[derive(Clone, Debug, Default)]
pub struct Person {
pub login: String,
pub display: Option<String>,
pub real: Option<String>,
pub first: String,
pub last: String,
pub commits: usize,
pub prs: usize,
pub added: u64,
pub removed: u64,
}
#[derive(Clone, Debug, Default)]
pub struct Model {
pub id: String,
pub company: String,
pub model: String,
pub version: String,
pub commits: usize,
pub added: u64,
pub removed: u64,
}
pub fn people() -> Vec<Person> {
let mut by: BTreeMap<String, Person> = BTreeMap::new();
for r in rows(COMMITS) {
let (Some(login), Some(date)) = (r.get(2), r.get(3)) else { continue };
let p = by.entry(login.to_string()).or_insert_with(|| Person {
login: login.to_string(),
first: date.to_string(),
last: date.to_string(),
..Default::default()
});
p.commits += 1;
p.added += r.get(4).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
p.removed += r.get(5).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
if *date < p.first.as_str() {
p.first = date.to_string();
}
if *date > p.last.as_str() {
p.last = date.to_string();
}
}
for r in rows(PRS) {
if let Some(p) = r.get(2).and_then(|l| by.get_mut(*l)) {
p.prs += 1;
}
}
for r in rows(PEOPLE) {
if r.get(1) == Some(&"yes")
&& let Some(p) = r.first().and_then(|l| by.get_mut(*l))
{
p.display = r.get(4).filter(|s| !s.is_empty()).map(|s| s.to_string());
p.real = r.get(5).filter(|s| !s.is_empty()).map(|s| s.to_string());
}
}
let mut v: Vec<Person> = by.into_values().filter(|p| !p.login.ends_with("[bot]")).collect();
v.sort_by(|a, b| a.first.cmp(&b.first));
v
}
pub fn models() -> (Vec<Model>, usize) {
let meta: BTreeMap<String, (String, String, String)> = rows(MODELS)
.iter()
.filter_map(|r| Some((r.first()?.to_string(), (r.get(1)?.to_string(), r.get(2)?.to_string(), r.get(3).unwrap_or(&"").to_string()))))
.collect();
let mut by: BTreeMap<String, Model> = BTreeMap::new();
let all = rows(COMMITS);
for r in &all {
for id in r.get(7).unwrap_or(&"").split(';').filter(|s| !s.is_empty()) {
let (company, model, version) = meta.get(id).cloned().unwrap_or_default();
let m = by.entry(id.to_string()).or_insert_with(|| Model { id: id.to_string(), company, model, version, ..Default::default() });
m.commits += 1;
m.added += r.get(4).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
m.removed += r.get(5).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
}
}
(by.into_values().collect(), all.len())
}
+62 -3
View File
@@ -38,7 +38,17 @@ impl Xf {
#[derive(Default)]
pub struct CanvasState {
pub list: Option<DisplayList>,
key: (u64, u64, i32),
key: (u64, u64, i32, bool),
/// Set when the app runs on wgpu: entities are drawn by [`crate::gpu`]; otherwise on the CPU.
pub gpu: Option<crate::gpu::GpuTarget>,
/// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]).
mesh_key: Option<u64>,
mesh_slot: crate::gpu::MeshSlot,
mesh_origin: Vec2,
/// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode).
infinite: Vec<usize>,
/// Milliseconds spent converting the display list to vertex data (last rebuild).
pub mesh_ms: f64,
pub rect: Option<Rect>,
pub xf: Option<Xf>,
pub hover: Option<Handle>,
@@ -62,7 +72,7 @@ fn ensure_list(app: &mut CadApp, px: f64) {
let Ok(st) = app.session.state() else { return };
// Rebuild when the drawing changes or the zoom moves by more than 2x (tessellation band).
let band = px.max(1e-300).log2().floor() as i32;
let key = (st.uid, st.revision, band);
let key = (st.uid, st.revision, band, app.session.settings.lwdisplay);
if app.canvas.list.is_some() && app.canvas.key == key {
return;
}
@@ -208,6 +218,51 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay:
p.extend(shapes);
}
/// Draw the display list through the GPU paint callback (uploading it when it changed), plus
/// the infinite lines, which are clipped to the view on the CPU every frame.
fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) {
let Some(list) = &c.list else { return };
let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300));
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(c.key, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h);
h.finish()
};
if c.mesh_key != Some(key) {
let t0 = crate::now_ms();
let mesh = crate::gpu::build_mesh(list, origin, bg, lwdisplay);
if let Ok(mut slot) = c.mesh_slot.lock() {
*slot = Some(mesh);
}
c.infinite = list.prims.iter().enumerate().filter(|(_, pr)| matches!(pr.kind, Kind::Infinite { .. })).map(|(i, _)| i).collect();
c.mesh_key = Some(key);
c.mesh_origin = origin;
c.mesh_ms = crate::now_ms() - t0;
}
let center = xf.rect.center();
p.add(egui_wgpu::Callback::new_paint_callback(
xf.rect,
crate::gpu::CanvasCallback {
key,
slot: c.mesh_slot.clone(),
offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32],
scale: xf.scale as f32,
center: [center.x, center.y],
},
));
let mut shapes = Vec::new();
for prim in c.infinite.iter().filter_map(|i| list.prims.get(*i)) {
let pts = list.points(prim);
if let (Kind::Infinite { ray }, Some(b), Some(d)) = (prim.kind, pts.first(), pts.get(1))
&& let Some(seg) = clip_infinite(xf, *b, *d, ray)
{
shapes.push(Shape::line_segment(seg, Stroke::new(1.0, color32(display_rgb(prim.color, bg)))));
}
}
p.extend(shapes);
}
/// Draw selected/hovered entities as highlight overlays.
fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, handles: &[Handle], color: Color32, width: f32, dashed: bool) {
if handles.is_empty() {
@@ -476,8 +531,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
ensure_list(app, 1.0 / scale);
let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b());
let sel = app.session.selection();
if let Some(list) = &app.canvas.list {
if app.canvas.gpu.is_some() {
draw_list_gpu(&mut app.canvas, &painter, &xf, bg, app.session.settings.lwdisplay);
} else if let Some(list) = &app.canvas.list {
draw_list(&painter, &xf, list, bg, app.session.settings.lwdisplay);
}
if let Some(list) = &app.canvas.list {
if let Some(h) = app.canvas.hover
&& !sel.contains(&h)
{
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
"cursor": app.canvas.cursor.map(|c| [c.x, c.y]),
"snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})),
"session": app.session.summary(),
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
})
}
+103 -12
View File
@@ -169,16 +169,104 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
fn about(ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
egui::Window::new("About CADCraft").open(open).resizable(false).collapsible(false).show(ctx, |ui| {
ui.heading("CADCraft");
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
ui.label("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust.");
ui.add_space(6.0);
ui.label(RichText::new("One of the Crafting Apps by the ArtCraft team and community.").color(t.text_dim));
ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft");
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
ui.add_space(6.0);
ui.label(RichText::new("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.").small().color(t.text_faint));
let tab_id = egui::Id::new("about_tab");
let sort_id = egui::Id::new("about_sort");
let name_id = egui::Id::new("about_names");
egui::Window::new("About CADCraft").open(open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, l) in ["About", "Contributors", "Models"].iter().enumerate() {
if ui.selectable_label(tab == i as u8, *l).clicked() {
tab = i as u8;
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
match tab {
1 => {
let mut names = ui.data_mut(|d| d.get_temp::<u8>(name_id)).unwrap_or(0);
ui.horizontal(|ui| {
ui.label("Show:");
for (i, l) in ["GitHub username", "Display name", "Real name"].iter().enumerate() {
if ui.selectable_label(names == i as u8, *l).clicked() {
names = i as u8;
}
}
});
ui.data_mut(|d| d.insert_temp(name_id, names));
let mut sort: (u8, bool) = ui.data_mut(|d| d.get_temp(sort_id)).unwrap_or((1, true));
let mut people = crate::about::people();
let name_of = |p: &crate::about::Person| match names {
1 => p.display.clone().unwrap_or_else(|| format!("@{}", p.login)),
2 => p.real.clone().unwrap_or_else(|| format!("@{}", p.login)),
_ => format!("@{}", p.login),
};
people.sort_by(|a, b| {
let o = match sort.0 {
0 => name_of(a).trim_start_matches('@').to_lowercase().cmp(&name_of(b).trim_start_matches('@').to_lowercase()),
1 => a.first.cmp(&b.first),
2 => a.last.cmp(&b.last),
3 => a.prs.cmp(&b.prs),
4 => a.commits.cmp(&b.commits),
5 => a.added.cmp(&b.added),
_ => a.removed.cmp(&b.removed),
};
if sort.1 { o } else { o.reverse() }
});
egui::Grid::new("contributors").striped(true).num_columns(7).show(ui, |ui| {
for (i, h) in ["Name", "First", "Last", "PRs", "Commits", "Added", "Removed"].iter().enumerate() {
let arrow = if sort.0 == i as u8 { if sort.1 { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{h}{arrow}")).strong()).clicked() {
sort = if sort.0 == i as u8 { (sort.0, !sort.1) } else { (i as u8, true) };
}
}
ui.end_row();
for p in &people {
ui.hyperlink_to(name_of(p), format!("https://github.com/{}", p.login));
ui.label(p.first.get(..10).unwrap_or(&p.first));
ui.label(p.last.get(..10).unwrap_or(&p.last));
ui.label(p.prs.to_string());
ui.label(p.commits.to_string());
ui.label(p.added.to_string());
ui.label(p.removed.to_string());
ui.end_row();
}
});
ui.data_mut(|d| d.insert_temp(sort_id, sort));
}
2 => {
let (models, total) = crate::about::models();
let assisted: usize = models.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::Grid::new("models").striped(true).num_columns(7).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of assisted", "% of all", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in &models {
ui.label(&m.company);
ui.label(&m.model);
ui.label(&m.version);
ui.label(m.commits.to_string());
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / assisted as f64));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / total.max(1) as f64));
ui.label(format!("+{} / −{}", m.added, m.removed));
ui.end_row();
}
});
}
_ => {
ui.heading("CADCraft");
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
ui.label("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust.");
ui.add_space(6.0);
ui.label(RichText::new("One of the Crafting Apps by the ArtCraft team and community.").color(t.text_dim));
ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft");
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
ui.add_space(6.0);
ui.label(RichText::new("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.").small().color(t.text_faint));
}
}
});
}
@@ -230,7 +318,8 @@ fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
/// The multiline text editor shown while MTEXT asks for its contents.
fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) {
let active = app.session.running.as_ref().is_some_and(|r| r.id == "mtext") && app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
let active = app.session.running.as_ref().is_some_and(|r| r.id == "mtext")
&& app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
let id = egui::Id::new("mtext_editor_buffer");
if !active {
ctx.data_mut(|d| d.remove::<String>(id));
@@ -262,7 +351,9 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) {
buf.push_str("%%c");
}
});
let r = ui.add(egui::TextEdit::multiline(&mut buf).desired_rows(6).desired_width(f32::INFINITY).hint_text("Type text; Enter starts a new paragraph"));
let r = ui.add(
egui::TextEdit::multiline(&mut buf).desired_rows(6).desired_width(f32::INFINITY).hint_text("Type text; Enter starts a new paragraph"),
);
if !r.has_focus() && buf.is_empty() {
r.request_focus();
}
+497
View File
@@ -0,0 +1,497 @@
//! GPU canvas: draws the drawing's display list with an `egui_wgpu` paint callback.
//!
//! The display list is converted once per (document, revision, tessellation band, background,
//! lineweight display, vertex origin) into vertex data ([`MeshData`]) and uploaded to wgpu
//! buffers; each frame only a small view uniform changes. Positions are stored as f32 relative to
//! a per-build origin so large world coordinates keep their precision; at deep zoom the origin
//! follows the view (see [`choose_origin`]).
//!
//! Batches: filled triangles (TriangleList), 1-px hairlines (LineList), and instanced screen-space
//! quads for wide lineweights and point markers. Fills are drawn first, then lines on top. The
//! shader only uses vertex buffers and one uniform buffer, so it runs on WebGPU and WebGL2.
//!
//! Infinite lines (xline/ray) and all overlays stay egui shapes (see `canvas.rs`), and the
//! canvas falls back to the CPU path when no wgpu render state was handed to the app.
use std::sync::{Arc, Mutex};
use cadcraft_color::{Rgb, display_rgb};
use cadcraft_geom::{Bounds2, Vec2};
use cadcraft_render::{DisplayList, Kind};
use egui_wgpu::wgpu;
use egui_wgpu::wgpu::util::DeviceExt;
/// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8).
const VERTEX_SIZE: usize = 12;
/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32).
const INSTANCE_SIZE: usize = 24;
/// Vertices (or instances) per GPU buffer; divisible by 2 and 3 so no primitive straddles two.
const CHUNK: usize = 4_194_300;
/// Point markers are 1.5 × 1.5 points (as on the CPU path).
const POINT_SIZE: f32 = 1.5;
/// The wgpu target the app renders into, recorded by [`install`].
#[derive(Clone, Copy, Debug)]
pub struct GpuTarget {
pub format: wgpu::TextureFormat,
}
/// CPU-side vertex data for one display list, waiting to be uploaded.
#[derive(Default)]
pub struct MeshData {
pub lines: Vec<u8>,
pub tris: Vec<u8>,
pub quads: Vec<u8>,
}
impl MeshData {
pub fn line_vertices(&self) -> usize {
self.lines.len() / VERTEX_SIZE
}
pub fn tri_vertices(&self) -> usize {
self.tris.len() / VERTEX_SIZE
}
pub fn quad_instances(&self) -> usize {
self.quads.len() / INSTANCE_SIZE
}
}
fn rel(p: Vec2, origin: Vec2) -> [f32; 2] {
[(p.x - origin.x) as f32, (p.y - origin.y) as f32]
}
fn push_vertex(out: &mut Vec<u8>, p: [f32; 2], c: [u8; 4]) {
out.extend_from_slice(&p[0].to_le_bytes());
out.extend_from_slice(&p[1].to_le_bytes());
out.extend_from_slice(&c);
}
fn push_instance(out: &mut Vec<u8>, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32) {
for v in [a[0], a[1], b[0], b[1]] {
out.extend_from_slice(&v.to_le_bytes());
}
out.extend_from_slice(&c);
out.extend_from_slice(&width.to_le_bytes());
}
/// Lineweight in screen points for a primitive (as on the CPU path), or `None` for a hairline.
pub fn wide_width(lw: f32, lwdisplay: bool) -> Option<f32> {
if !lwdisplay || lw <= 0.0 {
return None;
}
let w = (lw * 3.78).clamp(1.0, 12.0);
(w > 1.0).then_some(w)
}
/// Convert a display list into vertex data relative to `origin`, colours resolved against `bg`.
pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> MeshData {
let mut m = MeshData {
lines: Vec::with_capacity(list.segment_count().saturating_mul(2 * VERTEX_SIZE)),
tris: Vec::with_capacity(list.tris.len().saturating_mul(VERTEX_SIZE)),
quads: Vec::new(),
};
for prim in &list.prims {
let c = display_rgb(prim.color, bg);
let col = [c.0, c.1, c.2, 255];
let pts = list.points(prim);
match prim.kind {
Kind::Polyline => match wide_width(prim.lw, lwdisplay) {
Some(w) => {
for s in pts.windows(2) {
if let [a, b] = s {
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w);
}
}
}
None => {
for s in pts.windows(2) {
if let [a, b] = s {
push_vertex(&mut m.lines, rel(*a, origin), col);
push_vertex(&mut m.lines, rel(*b, origin), col);
}
}
}
},
Kind::Tris => {
for t in pts.as_chunks::<3>().0 {
for q in t {
push_vertex(&mut m.tris, rel(*q, origin), col);
}
}
}
Kind::Point => {
if let Some(q) = pts.first() {
let p = rel(*q, origin);
push_instance(&mut m.quads, p, p, col, POINT_SIZE);
}
}
// Drawn on the CPU each frame (clipped to the view).
Kind::Infinite { .. } => {}
}
}
m
}
/// The vertex origin for a display list seen at `view_center` / `view_height`: the list's centre
/// normally; at deep zoom (the view much smaller than the drawing) a point near the view, snapped
/// to a coarse grid so panning re-uploads only every few dozen screens.
pub fn choose_origin(bounds: &Bounds2, view_center: Vec2, view_height: f64) -> Vec2 {
let size = bounds.width().max(bounds.height());
if bounds.is_empty()
|| !view_center.is_finite()
|| view_height.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater)
|| view_height * 2000.0 >= size
{
return if bounds.is_empty() { Vec2::ZERO } else { bounds.center() };
}
let step = view_height * 64.0;
Vec2::new((view_center.x / step).round() * step, (view_center.y / step).round() * step)
}
/// Pending upload shared between the canvas (producer) and the paint callback (consumer).
pub type MeshSlot = Arc<Mutex<Option<MeshData>>>;
/// Per-frame parameters for the paint callback.
pub struct CanvasCallback {
/// Identifies the mesh in `slot`; the GPU buffers are rebuilt when it changes.
pub key: u64,
pub slot: MeshSlot,
/// (origin − view centre) in world units.
pub offset: [f32; 2],
/// Points per world unit.
pub scale: f32,
/// Canvas rect centre in points.
pub center: [f32; 2],
}
struct Batch {
buffer: wgpu::Buffer,
count: u32,
}
struct Resources {
line_pipeline: wgpu::RenderPipeline,
tri_pipeline: wgpu::RenderPipeline,
quad_pipeline: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind_group: wgpu::BindGroup,
linear_out: bool,
key: Option<u64>,
lines: Vec<Batch>,
tris: Vec<Batch>,
quads: Vec<Batch>,
}
const UNIFORM_SIZE: u64 = 48;
const SHADER: &str = r#"
struct View {
a: vec4<f32>, // offset x, offset y (world), scale (device px per unit), 1 = linear output
b: vec4<f32>, // canvas centre x, y (device px), screen w, h (device px)
c: vec4<f32>, // pixels per point
};
@group(0) @binding(0) var<uniform> view: View;
struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
};
fn to_px(p: vec2<f32>) -> vec2<f32> {
let w = (p + view.a.xy) * view.a.z;
return vec2<f32>(view.b.x + w.x, view.b.y - w.y);
}
fn px_to_clip(px: vec2<f32>) -> vec4<f32> {
return vec4<f32>(px.x / view.b.z * 2.0 - 1.0, 1.0 - px.y / view.b.w * 2.0, 0.0, 1.0);
}
fn out_color(c: vec4<f32>) -> vec4<f32> {
if (view.a.w > 0.5) {
let lo = c.rgb / 12.92;
let hi = pow((c.rgb + vec3<f32>(0.055)) / 1.055, vec3<f32>(2.4));
return vec4<f32>(select(hi, lo, c.rgb <= vec3<f32>(0.04045)), c.a);
}
return c;
}
@vertex
fn vs_line(@location(0) pos: vec2<f32>, @location(1) color: vec4<f32>) -> VOut {
var o: VOut;
o.pos = px_to_clip(to_px(pos));
o.color = out_color(color);
return o;
}
@vertex
fn vs_quad(
@builtin(vertex_index) vi: u32,
@location(0) a: vec2<f32>,
@location(1) b: vec2<f32>,
@location(2) color: vec4<f32>,
@location(3) width: f32,
) -> VOut {
let pa = to_px(a);
let pb = to_px(b);
let d = pb - pa;
let len = length(d);
var dir = vec2<f32>(1.0, 0.0);
if (len > 0.0001) {
dir = d / len;
}
let n = vec2<f32>(-dir.y, dir.x);
// Width arrives in points.
let hw = 0.5 * width * view.c.x;
let t = select(0.0, 1.0, vi == 1u || vi == 4u || vi == 5u);
let s = select(-1.0, 1.0, vi == 2u || vi == 3u || vi == 5u);
let along = mix(pa - dir * hw, pb + dir * hw, t);
var o: VOut;
o.pos = px_to_clip(along + n * hw * s);
o.color = out_color(color);
return o;
}
@fragment
fn fs_main(v: VOut) -> @location(0) vec4<f32> {
return v.color;
}
"#;
fn vertex_layout() -> wgpu::VertexBufferLayout<'static> {
const ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Unorm8x4];
wgpu::VertexBufferLayout { array_stride: VERTEX_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &ATTRS }
}
fn instance_layout() -> wgpu::VertexBufferLayout<'static> {
const ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32];
wgpu::VertexBufferLayout { array_stride: INSTANCE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &ATTRS }
}
impl Resources {
fn new(device: &wgpu::Device, target: GpuTarget) -> Self {
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("cad_canvas"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
};
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("cad_canvas"), entries: &[entry(0)] });
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("cad_canvas"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("cad_canvas_view"),
size: UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("cad_canvas"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
});
let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vs),
buffers: &[Some(buffer)],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology, ..Default::default() },
depth_stencil: None,
multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: target.format,
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
Resources {
line_pipeline: pipeline("cad_lines", "vs_line", wgpu::PrimitiveTopology::LineList, vertex_layout()),
tri_pipeline: pipeline("cad_fills", "vs_line", wgpu::PrimitiveTopology::TriangleList, vertex_layout()),
quad_pipeline: pipeline("cad_quads", "vs_quad", wgpu::PrimitiveTopology::TriangleList, instance_layout()),
uniform,
bind_group,
linear_out: target.format.is_srgb(),
key: None,
lines: Vec::new(),
tris: Vec::new(),
quads: Vec::new(),
}
}
}
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Vec<Batch> {
bytes
.chunks(CHUNK * stride)
.filter(|c| c.len() >= stride)
.map(|c| Batch {
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: c, usage }),
count: u32::try_from(c.len() / stride).unwrap_or(0),
})
.collect()
}
/// Create the canvas pipelines in the renderer's callback resources. Call once with the app's
/// render state (eframe `CreationContext::wgpu_render_state`).
pub fn install(rs: &egui_wgpu::RenderState) -> GpuTarget {
let target = GpuTarget { format: rs.target_format };
let res = Resources::new(&rs.device, target);
rs.renderer.write().callback_resources.insert(res);
log::info!("gpu canvas: target {:?}", rs.target_format);
target
}
impl egui_wgpu::CallbackTrait for CanvasCallback {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
screen: &egui_wgpu::ScreenDescriptor,
_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
if res.key != Some(self.key) {
let pending = self.slot.lock().ok().and_then(|mut s| s.take());
if let Some(m) = pending {
let usage = wgpu::BufferUsages::VERTEX;
res.lines = upload(device, "cad_lines", &m.lines, VERTEX_SIZE, usage);
res.tris = upload(device, "cad_fills", &m.tris, VERTEX_SIZE, usage);
res.quads = upload(device, "cad_quads", &m.quads, INSTANCE_SIZE, usage);
res.key = Some(self.key);
}
}
let ppp = screen.pixels_per_point;
let [w, h] = screen.size_in_pixels;
let a = [self.offset[0], self.offset[1], self.scale * ppp, if res.linear_out { 1.0 } else { 0.0 }];
let b = [self.center[0] * ppp, self.center[1] * ppp, w.max(1) as f32, h.max(1) as f32];
let c = [ppp, 0.0, 0.0, 0.0];
let bytes: Vec<u8> = a.iter().chain(&b).chain(&c).flat_map(|v| v.to_le_bytes()).collect();
queue.write_buffer(&res.uniform, 0, &bytes);
Vec::new()
}
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
let Some(res) = resources.get::<Resources>() else { return };
if res.key != Some(self.key) {
return;
}
let [sw, sh] = info.screen_size_px;
if sw == 0 || sh == 0 {
return;
}
// Whole-screen device pixels; egui already set the scissor to the canvas clip rect.
pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0);
pass.set_bind_group(0, &res.bind_group, &[]);
pass.set_pipeline(&res.tri_pipeline);
for b in &res.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
pass.set_pipeline(&res.line_pipeline);
for b in &res.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
pass.set_pipeline(&res.quad_pipeline);
for b in &res.quads {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_doc::Handle;
use cadcraft_render::DPrim;
fn list() -> DisplayList {
let mut l = DisplayList {
verts: vec![Vec2::new(1e6, 1e6), Vec2::new(1e6 + 1.0, 1e6), Vec2::new(1e6 + 1.0, 1e6 + 1.0), Vec2::new(5.0, 5.0)],
tris: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)],
..Default::default()
};
let p = |kind, start, len, lw| DPrim { handle: Handle(1), color: Rgb(255, 255, 255), lw, kind, start, len };
l.prims =
vec![p(Kind::Polyline, 0, 3, 0.0), p(Kind::Tris, 0, 3, 0.0), p(Kind::Point, 3, 1, 0.0), p(Kind::Infinite { ray: false }, 0, 2, 0.0)];
for v in l.verts.clone() {
l.bounds.add(v);
}
l
}
#[test]
fn mesh_counts_and_precision() {
let l = list();
let origin = Vec2::new(1e6, 1e6);
let m = build_mesh(&l, origin, Rgb(255, 255, 255), false);
assert_eq!(m.line_vertices(), 4, "two segments");
assert_eq!(m.tri_vertices(), 3);
assert_eq!(m.quad_instances(), 1, "the point marker");
// First vertex is stored relative to the origin, so it's exactly zero.
assert_eq!(&m.lines[0..8], &[0u8; 8]);
// White on a white background inverts to black (display_rgb).
assert_eq!(&m.lines[8..12], &[0, 0, 0, 255]);
let x = f32::from_le_bytes([m.lines[12], m.lines[13], m.lines[14], m.lines[15]]);
assert_eq!(x, 1.0);
}
#[test]
fn lineweights_become_quads() {
let mut l = list();
if let Some(p) = l.prims.first_mut() {
p.lw = 0.7;
}
let m = build_mesh(&l, Vec2::ZERO, Rgb(0, 0, 0), true);
assert_eq!(m.line_vertices(), 0);
assert_eq!(m.quad_instances(), 3);
assert_eq!(wide_width(0.25, true), None, "thin lineweights stay hairlines");
assert_eq!(wide_width(0.7, false), None);
}
#[test]
#[allow(clippy::unwrap_used)]
fn shader_is_valid_for_webgpu_and_webgl2() {
let module = naga::front::wgsl::parse_str(SHADER).unwrap();
let info = naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
for (stage, entry) in
[(naga::ShaderStage::Vertex, "vs_line"), (naga::ShaderStage::Vertex, "vs_quad"), (naga::ShaderStage::Fragment, "fs_main")]
{
let options = naga::back::glsl::Options { version: naga::back::glsl::Version::new_gles(300), ..Default::default() };
let pipeline = naga::back::glsl::PipelineOptions { shader_stage: stage, entry_point: entry.into(), multiview: None };
let mut out = String::new();
let mut w =
naga::back::glsl::Writer::new(&mut out, &module, &info, &options, &pipeline, naga::proc::BoundsCheckPolicies::default()).unwrap();
w.write().unwrap();
assert!(out.contains("#version 300 es"), "{entry}");
}
}
#[test]
fn origin_follows_deep_zoom() {
let b = Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(1e6, 1e6));
assert_eq!(choose_origin(&b, Vec2::new(10.0, 10.0), 1e5), b.center());
let o = choose_origin(&b, Vec2::new(123_456.7, 654_321.2), 1.0);
assert!((o.x - 123_456.7).abs() <= 32.0 && (o.y - 654_321.2).abs() <= 32.0);
assert_eq!(choose_origin(&Bounds2::EMPTY, Vec2::new(1.0, 1.0), 1.0), Vec2::ZERO);
assert_eq!(choose_origin(&b, Vec2::new(f64::NAN, 0.0), 1.0), b.center());
}
}
+8
View File
@@ -8,11 +8,13 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod about;
pub mod canvas;
pub mod chrome;
pub mod cmdline;
pub mod control;
pub mod dialogs;
pub mod gpu;
pub mod icons;
pub mod menus;
pub mod palettes;
@@ -119,6 +121,12 @@ impl CadApp {
}
}
/// Draw the canvas on the GPU with the app's wgpu render state (eframe's
/// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) {
self.canvas.gpu = Some(gpu::install(rs));
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
+47
View File
@@ -0,0 +1,47 @@
# CADCraft and AI agents (MCP)
CADCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio.
## Start it
```sh
# Headless: an in-process drawing session, no window.
cadcraft-cli mcp
# Bridged to the running app (watch the agent draw):
cadcraft --control 7979 &
cadcraft-cli mcp --connect 127.0.0.1:7979
```
Example client configuration (Claude Code / Claude Desktop):
```json
{ "mcpServers": { "cadcraft": { "command": "cadcraft-cli", "args": ["mcp"] } } }
```
## Tools
| Tool | What it does |
|---|---|
| `command_line {text}` | Type at the command line exactly like a person: `line 0,0 @10,0 @0,5 c`, `circle 5,2 1`, `offset 0.5`, `zoom e`. Spaces act as Enter; empty text is Enter. Returns the new prompt, keywords and output. |
| `script {text}` | A multi-line script (like `.scr` files). |
| `execute {command, params}` | Any command with JSON parameters, never a dialog. `list_commands` shows ids and parameter docs. |
| `list_commands {filter?}` | The command catalog. |
| `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. |
| `query_entities {type?, layer?, window?}` | Filtered entities with handles and geometry. |
| `render {width?, height?, fit?}` | A PNG image of the drawing. |
| `new_drawing {metric?}`, `open {path}`, `save {path?}`, `cancel` | Files and Escape. |
| `screenshot`, `ui_inspect`, `ui_click {x, y}` | Only when connected to the app. |
Resources: `cadcraft://drawing` (inspect JSON) and `cadcraft://commands` (catalog).
## A typical session
1. `command_line {"text": "rectang 0,0 100,60"}`
2. `execute {"command": "circle", "params": {"center": [10, 10], "radius": 4}}`
3. `command_line {"text": "dimlinear 0,0 100,0 50,-8"}`
4. `inspect_drawing {}` — verify counts and geometry.
5. `render {}` — look at it.
The end-to-end test in `crates/mcp/src/tests.rs` runs a session like this and checks the result
only through MCP.
+9 -62
View File
@@ -2,19 +2,19 @@
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: 151 / 491 items (31%)** · 159 registered commands.
**Menu breadth: 204 / 491 items (42%)** · 227 registered commands.
| Menu | Items | Covered | % |
|---|---|---|---|
| File | 22 | 6 | 27% |
| Edit | 13 | 9 | 69% |
| View | 75 | 22 | 29% |
| Insert | 11 | 1 | 9% |
| File | 22 | 9 | 41% |
| Edit | 13 | 10 | 77% |
| View | 75 | 27 | 36% |
| Insert | 11 | 2 | 18% |
| Format | 27 | 21 | 78% |
| Tools | 85 | 19 | 22% |
| Draw | 87 | 26 | 30% |
| Dimension | 28 | 18 | 64% |
| Modify | 113 | 27 | 24% |
| Tools | 85 | 23 | 27% |
| Draw | 87 | 54 | 62% |
| Dimension | 28 | 19 | 68% |
| Modify | 113 | 37 | 33% |
| Window | 22 | 2 | 9% |
| Help | 8 | 0 | 0% |
@@ -26,20 +26,16 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- File > Import...
- File > Attach...
- File > Export...
- File > Export to PDF...
- File > Export Layout to Model...
- File > Package Drawing...
- File > DWG Compare...
- File > Markup Import...
- File > Page Setup Manager...
- File > Plot Styles...
- File > Print...
- File > Batch Publish...
- File > Share...
- Edit > Undo Group of commands
- Edit > Cut with Base Point
- Edit > Paste as Block
- Edit > Find...
- View > Zoom > Dynamic
- View > Pan > Point
- View > Orbit > Constrained Orbit
@@ -47,11 +43,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- View > Camera > Adjust Distance
- View > Camera > Swivel
- View > Viewports > Named Viewports...
- View > Viewports > New Viewports...
- View > Viewports > 1 Viewport
- View > Viewports > 2 Viewports
- View > Viewports > 3 Viewports
- View > Viewports > 4 Viewports
- View > Viewports > Polygonal Viewport
- View > 3D Views > Viewpoint
- View > 3D Views > Plan View > Current UCS
@@ -98,7 +89,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Insert > Raster Image Reference...
- Insert > Field...
- Insert > Data Link...
- Insert > Layout > New Layout
- Insert > Layout > Layout from Template...
- Insert > ACIS File...
- Insert > Drawing Exchange Binary...
@@ -120,10 +110,7 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Tools > Isolate > Isolate Objects
- Tools > Isolate > Hide Objects
- Tools > Isolate > End Object Isolation
- Tools > Inquiry > Quick
- Tools > Inquiry > Angle
- Tools > Inquiry > Volume
- Tools > Inquiry > Region/Mass Properties
- Tools > Update Fields
- Tools > Block Editor
- Tools > Edit Xref in Place
@@ -173,7 +160,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Tools > Express Tools > Replace Block...
- Tools > Express Tools > Super Hatch > Image...
- Tools > Express Tools > Super Hatch > Xref...
- Tools > Express Tools > Super Hatch > Wipeout
- Tools > Express Tools > Super Hatch > Select Existing Object
- Draw > 3D Modeling > Polysolid
- Draw > 3D Modeling > Box
@@ -190,7 +176,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Draw > 3D Modeling > Section Plane
- Draw > 3D Modeling > Surfaces > Planar
- Draw > 3D Modeling > Surfaces > Network
- Draw > 3D Modeling > Surfaces > Blend
- Draw > 3D Modeling > Surfaces > Patch
- Draw > 3D Modeling > Meshes > Primitives > Box
- Draw > 3D Modeling > Meshes > Primitives > Wedge
@@ -208,34 +193,7 @@ 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 > 3D Polyline
- Draw > Spline > Control Vertices
- Draw > Multiline
- Draw > Arc > Start, Center, End
- Draw > Arc > Start, Center, Angle
- Draw > Arc > Start, Center, Length
- Draw > Arc > Start, End, Angle
- Draw > Arc > Start, End, Direction
- Draw > Arc > Start, End, Radius
- Draw > Arc > Center, Start, End
- Draw > Arc > Center, Start, Angle
- Draw > Arc > Center, Start, Length
- Draw > Circle > Center, Diameter
- Draw > Circle > 2 Points
- Draw > Circle > Tan, Tan, Radius
- Draw > Circle > Tan, Tan, Tan
- Draw > Ellipse > Axis, End
- Draw > Ellipse > Arc
- Draw > Revision Cloud > Rectangular
- Draw > Revision Cloud > Polygonal
- Draw > Revision Cloud > Freehand
- Draw > Point > Divide
- Draw > Point > Measure
- Draw > Helix
- Draw > Wipeout
- Draw > Table...
- Draw > Center Line
- Draw > Center Mark
- Dimension > Jogged
- Dimension > Dimension Space
- Dimension > Dimension Break
@@ -244,7 +202,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Dimension > Jogged Linear
- Dimension > Oblique
- Dimension > Align Text > Home
- Dimension > Align Text > Angle
- Dimension > Reassociate Dimensions
- Modify > Object > External Reference > Bind...
- Modify > Object > External Reference > Frame
@@ -252,16 +209,13 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Modify > Object > Image > Quality
- Modify > Object > Image > Transparency
- Modify > Object > Image > Frame
- Modify > Object > Spline
- Modify > Object > Array
- Modify > Object > Multileader > Add Leader
- Modify > Object > Multileader > Remove Leader
- Modify > Object > Multileader > Align
- Modify > Object > Multileader > Collect
- Modify > Object > Multiline...
- Modify > Object > Attribute > Global
- Modify > Object > Block Description...
- Modify > Object > Edit Text...
- Modify > Clip > Image
- Modify > Clip > Xref
- Modify > Clip > Viewport
@@ -269,13 +223,8 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Modify > Annotative Object Scale > Delete Current Scale
- Modify > Annotative Object Scale > Add/Delete Scales...
- Modify > Annotative Object Scale > Synchronize Multiple-scale Positions
- Modify > Array > Path Array
- Modify > Copy Nested Objects
- Modify > Lengthen
- Modify > Blend
- Modify > 3D Operations > 3D Move
- Modify > 3D Operations > 3D Rotate
- Modify > 3D Operations > Align
- Modify > 3D Operations > 3D Align
- Modify > 3D Operations > 3D Mirror
- Modify > 3D Operations > 3D Array
@@ -330,8 +279,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
- Modify > Mesh Editing > Convert to Faceted Surface
- Modify > Mesh Editing > Convert to Smooth Solid
- Modify > Mesh Editing > Convert to Smooth Surface
- Modify > Change Space
- Modify > Flatten Objects
- Window > Minimize
- Window > Minimize All
- Window > Zoom
+144
View File
@@ -0,0 +1,144 @@
//! `cargo xtask contributors`: refresh contributors/commits.tsv and prs.tsv from the local git
//! history and GitHub (via `gh`), per craftrules standards/contributors.md. Never writes
//! people.tsv or models.tsv. Git author names and emails are never recorded.
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
const REPO: &str = "storytold/cadcraft";
fn run(root: &Path, cmd: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new(cmd).current_dir(root).args(args).output().map_err(|e| format!("{cmd}: {e}"))?;
if !out.status.success() {
return Err(format!("{cmd} {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn trailer_names(models: &str) -> HashMap<String, String> {
models
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("id\t"))
.filter_map(|l| {
let c: Vec<&str> = l.split('\t').collect();
Some((c.get(4)?.to_string(), c.first()?.to_string()))
})
.collect()
}
pub fn run_cmd(root: &Path) -> Result<(), String> {
let models = std::fs::read_to_string(root.join("contributors/models.tsv")).unwrap_or_default();
let map = trailer_names(&models);
// Commit → GitHub login (paginated; the only network step).
let mut logins: HashMap<String, String> = HashMap::new();
for page in 1..=200 {
let path = format!("repos/{REPO}/commits?per_page=100&page={page}");
let Ok(out) = run(root, "gh", &["api", &path, "--jq", ".[] | [.sha, (.author.login // \"unknown\")] | @tsv"]) else { break };
if out.trim().is_empty() {
break;
}
for l in out.lines() {
if let Some((sha, login)) = l.split_once('\t') {
logins.insert(sha.to_string(), login.to_string());
}
}
}
// PR numbers by merge commit.
let mut prs_rows = Vec::new();
let mut pr_of: HashMap<String, String> = HashMap::new();
if let Ok(out) = run(
root,
"gh",
&[
"pr",
"list",
"--repo",
REPO,
"--state",
"merged",
"--limit",
"1000",
"--json",
"number,url,author,mergedAt,additions,deletions,commits,mergeCommit",
"--jq",
".[] | [.number, .url, .author.login, .mergedAt, .additions, .deletions, (.commits|length), .mergeCommit.oid, ([.commits[].oid]|join(\",\"))] | @tsv",
],
) {
for l in out.lines() {
let c: Vec<&str> = l.split('\t').collect();
if c.len() < 9 {
continue;
}
for sha in c[8].split(',') {
pr_of.insert(sha.to_string(), c[0].to_string());
}
prs_rows.push(c[..7].join("\t"));
}
}
// Local history with numstat and trailers.
let out = Command::new("git")
.current_dir(root)
.env("TZ", "UTC")
.args([
"log",
"--no-merges",
"--date=format-local:%Y-%m-%dT%H:%M:%SZ",
"--format=@@%H\t%cd\t%(trailers:key=Co-Authored-By,valueonly,separator=;)",
"--numstat",
])
.output()
.map_err(|e| format!("git: {e}"))?;
let log = String::from_utf8_lossy(&out.stdout).to_string();
let mut rows: Vec<(String, String)> = Vec::new();
let mut cur: Option<(String, String, String, u64, u64)> = None;
let flush = |cur: &mut Option<(String, String, String, u64, u64)>, rows: &mut Vec<(String, String)>| {
if let Some((sha, date, trailers, add, del)) = cur.take() {
let login = logins.get(&sha).cloned().unwrap_or_else(|| "unknown".into());
let mut ms: Vec<String> = trailers
.split(';')
.filter_map(|t| {
let name = t.trim().split('<').next()?.trim().to_string();
map.get(&name).cloned()
})
.collect();
ms.sort();
ms.dedup();
let pr = pr_of.get(&sha).cloned().unwrap_or_default();
rows.push((
date.clone(),
format!("{sha}\thttps://github.com/{REPO}/commit/{sha}\t{login}\t{date}\t{add}\t{del}\t{pr}\t{}", ms.join(";")),
));
}
};
for l in log.lines() {
if let Some(rest) = l.strip_prefix("@@") {
flush(&mut cur, &mut rows);
let c: Vec<&str> = rest.splitn(3, '\t').collect();
cur = Some((c.first().unwrap_or(&"").to_string(), c.get(1).unwrap_or(&"").to_string(), c.get(2).unwrap_or(&"").to_string(), 0, 0));
} else if let Some(c) = cur.as_mut() {
let p: Vec<&str> = l.split('\t').collect();
if p.len() == 3 {
c.3 += p[0].parse::<u64>().unwrap_or(0);
c.4 += p[1].parse::<u64>().unwrap_or(0);
}
}
}
flush(&mut cur, &mut rows);
rows.sort();
let mut commits = String::from("sha\turl\tlogin\tdate\tadditions\tdeletions\tpr\tmodels\n");
for (_, r) in &rows {
commits.push_str(r);
commits.push('\n');
}
std::fs::write(root.join("contributors/commits.tsv"), commits).map_err(|e| e.to_string())?;
let mut prs = String::from("number\turl\tlogin\tmerged_at\tadditions\tdeletions\tcommits\tmodels\n");
prs_rows.sort();
for r in prs_rows {
prs.push_str(&r);
prs.push_str("\t\n");
}
std::fs::write(root.join("contributors/prs.tsv"), prs).map_err(|e| e.to_string())?;
println!("contributors: {} commits", rows.len());
Ok(())
}
+3
View File
@@ -4,6 +4,7 @@
//! invoked through `std::process::Command`.
mod assets;
mod contributors;
mod ico;
mod layers;
mod parity;
@@ -25,6 +26,7 @@ commands:
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity recompute the AutoCAD feature-parity summary in docs/parity.md
contributors refresh contributors/commits.tsv and prs.tsv (git + gh)
ico <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
";
@@ -42,6 +44,7 @@ fn main() -> ExitCode {
Some("parity") => parity::run(&root()),
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
Some("contributors") => contributors::run_cmd(&root()),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())