From 3da0219ac9f63e5f95bcfcbbe0a96a1b445df185 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 00:40:03 -0400 Subject: [PATCH] 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 --- Cargo.lock | 65 +- ROADMAP.md | 24 +- apps/cadcraft-cli/src/main.rs | 138 ++ apps/cadcraft-web/src/main.rs | 6 +- apps/cadcraft/src/main.rs | 4 + contributors/commits.tsv | 6 + contributors/models.tsv | 6 + contributors/people.tsv | 3 + contributors/prs.tsv | 1 + crates/doc/src/store.rs | 5 + crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/curves.rs | 761 ++++++++ crates/engine/src/cmd/draw.rs | 223 +-- crates/engine/src/cmd/draw2.rs | 2472 ++++++++++++++++++++++++ crates/engine/src/cmd/draw2_tests.rs | 680 +++++++ crates/engine/src/cmd/file.rs | 5 + crates/engine/src/cmd/gripcmds.rs | 107 + crates/engine/src/cmd/layout.rs | 887 +++++++++ crates/engine/src/cmd/mod.rs | 11 + crates/engine/src/cmd/modify.rs | 33 +- crates/engine/src/cmd/modify2.rs | 2301 ++++++++++++++++++++++ crates/engine/src/cmd/modify2_tests.rs | 607 ++++++ crates/engine/src/cmd/utility.rs | 449 +++++ crates/engine/src/grips.rs | 329 ++++ crates/engine/src/grips_tests.rs | 224 +++ crates/engine/src/lib.rs | 2 + crates/engine/src/select.rs | 76 +- crates/engine/src/snap.rs | 25 +- crates/engine/src/spatial.rs | 373 ++++ crates/fonts/Cargo.toml | 1 + crates/fonts/src/lib.rs | 24 + crates/fonts/src/ttf.rs | 202 ++ crates/io/Cargo.toml | 4 +- crates/io/src/dxf_read.rs | 36 +- crates/io/src/dxf_write.rs | 4 +- crates/io/src/lib.rs | 6 +- crates/io/src/pdf.rs | 370 ++++ crates/io/src/tests.rs | 139 ++ crates/render/src/clip.rs | 197 ++ crates/render/src/lib.rs | 185 +- crates/render/src/paper.rs | 128 ++ crates/render/src/tests.rs | 68 + crates/ui-egui/Cargo.toml | 6 + crates/ui-egui/build.rs | 18 + crates/ui-egui/src/about.rs | 94 + crates/ui-egui/src/canvas.rs | 65 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs.rs | 115 +- crates/ui-egui/src/gpu.rs | 497 +++++ crates/ui-egui/src/lib.rs | 8 + docs/mcp.md | 47 + docs/parity.md | 71 +- xtask/src/contributors.rs | 144 ++ xtask/src/main.rs | 3 + 54 files changed, 11914 insertions(+), 345 deletions(-) create mode 100644 contributors/commits.tsv create mode 100644 contributors/models.tsv create mode 100644 contributors/people.tsv create mode 100644 contributors/prs.tsv create mode 100644 crates/engine/src/cmd/curves.rs create mode 100644 crates/engine/src/cmd/draw2.rs create mode 100644 crates/engine/src/cmd/draw2_tests.rs create mode 100644 crates/engine/src/cmd/gripcmds.rs create mode 100644 crates/engine/src/cmd/layout.rs create mode 100644 crates/engine/src/cmd/modify2.rs create mode 100644 crates/engine/src/cmd/modify2_tests.rs create mode 100644 crates/engine/src/cmd/utility.rs create mode 100644 crates/engine/src/grips.rs create mode 100644 crates/engine/src/grips_tests.rs create mode 100644 crates/engine/src/spatial.rs create mode 100644 crates/fonts/src/ttf.rs create mode 100644 crates/io/src/pdf.rs create mode 100644 crates/render/src/clip.rs create mode 100644 crates/render/src/paper.rs create mode 100644 crates/ui-egui/build.rs create mode 100644 crates/ui-egui/src/about.rs create mode 100644 crates/ui-egui/src/gpu.rs create mode 100644 docs/mcp.md create mode 100644 xtask/src/contributors.rs diff --git a/Cargo.lock b/Cargo.lock index d28b2d74..305d507c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/ROADMAP.md b/ROADMAP.md index 8bac59b3..b5e47a7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,9 +8,9 @@ agent control over MCP, a scriptable CLI, a web build and a free licence. | Measure | Value | |---|---| -| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **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 diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index f29b4a0d..9169fa9f 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -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 { + 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 = (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 = (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 = 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()); diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 79adfa1f..af50a2bb 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -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!({})); } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 9f77d995..fb6876da 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -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); diff --git a/contributors/commits.tsv b/contributors/commits.tsv new file mode 100644 index 00000000..79e23acf --- /dev/null +++ b/contributors/commits.tsv @@ -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 diff --git a/contributors/models.tsv b/contributors/models.tsv new file mode 100644 index 00000000..6813a3e3 --- /dev/null +++ b/contributors/models.tsv @@ -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 diff --git a/contributors/people.tsv b/contributors/people.tsv new file mode 100644 index 00000000..511e2711 --- /dev/null +++ b/contributors/people.tsv @@ -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 diff --git a/contributors/prs.tsv b/contributors/prs.tsv new file mode 100644 index 00000000..08fd89b2 --- /dev/null +++ b/contributors/prs.tsv @@ -0,0 +1 @@ +number url login merged_at additions deletions commits models diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs index ad7ddef0..5b4a8631 100644 --- a/crates/doc/src/store.rs +++ b/crates/doc/src/store.rs @@ -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> { self.chunks.last().and_then(|c| c.items.last()) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index edfde56d..c68b7955 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs new file mode 100644 index 00000000..a98c8d20 --- /dev/null +++ b/crates/engine/src/cmd/curves.rs @@ -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 { + let space = s.space(); + crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s)) +} + +pub fn entity(s: &Session, h: Handle) -> Result { + 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 { + 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 { + 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 { + (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 { + 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 { + 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 { + 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 { + 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 { + 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::>().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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + pub closed: bool, +} + +impl Chain { + pub fn of(k: &EntityKind) -> Option { + let tess = |pts: Vec, 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 = 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 { + self.segs.first().map(Segment::start) + } + pub fn end(&self) -> Option { + 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 { + let mut vs: Vec = 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 { + 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 = 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 { + 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 { + 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); + } +} diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 126c2a70..953ad4c7 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -31,12 +31,12 @@ pub fn specs() -> Vec { .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 { .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 { added(s.add_entity(arc(&a))?) } -fn run_rectang(s: &mut Session, p: &Value) -> Result { - 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 { 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 { Ok(json!({ "handles": hs })) } -fn run_xline(s: &mut Session, p: &Value) -> Result { - 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 { 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, diameter: bool, + /// The `Ttr` option hands over to the tangent-circle machine. + delegate: Option>, } 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 { + 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 { + fn preview(&self, s: &Session, c: Vec2) -> Vec { + 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, - center_first: bool, - center: Option, -} - -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 { - 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 { - 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, - 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 { - 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 { - 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, @@ -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 { + 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), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs new file mode 100644 index 00000000..f6813c8e --- /dev/null +++ b/crates/engine/src/cmd/draw2.rs @@ -0,0 +1,2472 @@ +//! Draw menu depth (M1/M2): every ARC and CIRCLE variant, ELLIPSE axis/arc entries, RECTANG +//! options, DIVIDE/MEASURE, REVCLOUD, WIPEOUT, XLINE options, 3DPOLY, MLINE, HELIX, spline by +//! control vertices, CENTERMARK and CENTERLINE. Each has a JSON form and a prompt sequence. + +use cadcraft_doc::{EntityKind, Handle, Point, Polyline3d, RayLine, Wipeout}; +use cadcraft_geom::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Spline, TAU, Vec2, Vec3, norm_angle, shoelace}; +use serde_json::{Value, json}; + +use super::curves::{self, Chain, MAX_GEN, TanObj}; +use super::helpers::*; +use super::machines::number; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("arc.sce", "Start, Center, End", run_arc_variant) + .menu(&["Draw", "Arc", "Start, Center, End"]) + .params("{start, center, end}") + .interactive(|_| Ok(seq(ArcV::Sce))), + CommandSpec::new("arc.sca", "Start, Center, Angle", run_arc_variant) + .menu(&["Draw", "Arc", "Start, Center, Angle"]) + .params("{start, center, angle (degrees, + = CCW)}") + .interactive(|_| Ok(seq(ArcV::Sca))), + CommandSpec::new("arc.scl", "Start, Center, Length", run_arc_variant) + .menu(&["Draw", "Arc", "Start, Center, Length"]) + .params("{start, center, length (chord; negative = major arc)}") + .interactive(|_| Ok(seq(ArcV::Scl))), + CommandSpec::new("arc.sea", "Start, End, Angle", run_arc_variant) + .menu(&["Draw", "Arc", "Start, End, Angle"]) + .params("{start, end, angle (degrees, + = CCW)}") + .interactive(|_| Ok(seq(ArcV::Sea))), + CommandSpec::new("arc.sed", "Start, End, Direction", run_arc_variant) + .menu(&["Draw", "Arc", "Start, End, Direction"]) + .params("{start, end, direction (degrees | [dx,dy])}") + .interactive(|_| Ok(seq(ArcV::Sed))), + CommandSpec::new("arc.ser", "Start, End, Radius", run_arc_variant) + .menu(&["Draw", "Arc", "Start, End, Radius"]) + .params("{start, end, radius (negative = major arc)}") + .interactive(|_| Ok(seq(ArcV::Ser))), + CommandSpec::new("arc.cse", "Center, Start, End", run_arc_variant) + .menu(&["Draw", "Arc", "Center, Start, End"]) + .params("{center, start, end}") + .interactive(|_| Ok(seq(ArcV::Cse))), + CommandSpec::new("arc.csa", "Center, Start, Angle", run_arc_variant) + .menu(&["Draw", "Arc", "Center, Start, Angle"]) + .params("{center, start, angle (degrees)}") + .interactive(|_| Ok(seq(ArcV::Csa))), + CommandSpec::new("arc.csl", "Center, Start, Length", run_arc_variant) + .menu(&["Draw", "Arc", "Center, Start, Length"]) + .params("{center, start, length (chord)}") + .interactive(|_| Ok(seq(ArcV::Csl))), + CommandSpec::new("arc.continue", "Continue", run_arc_continue) + .menu(&["Draw", "Arc", "Continue"]) + .params("{end} (tangent from the last line/arc) | {start, direction, end}") + .interactive(|_| Ok(Box::new(ArcContinueM::default()))), + CommandSpec::new("circle.cd", "Center, Diameter", run_circle_variant) + .menu(&["Draw", "Circle", "Center, Diameter"]) + .params("{center, diameter}") + .interactive(|_| Ok(seq(ArcV::CircleCd))), + CommandSpec::new("circle.2p", "2 Points", run_circle_variant) + .menu(&["Draw", "Circle", "2 Points"]) + .params("{p1, p2}") + .interactive(|_| Ok(seq(ArcV::Circle2p))), + CommandSpec::new("circle.3p", "3 Points", run_circle_variant) + .menu(&["Draw", "Circle", "3 Points"]) + .params("{p1, p2, p3}") + .interactive(|_| Ok(seq(ArcV::Circle3p))), + CommandSpec::new("circle.ttr", "Tan, Tan, Radius", run_circle_ttr) + .menu(&["Draw", "Circle", "Tan, Tan, Radius"]) + .params("{h1, p1, h2, p2, radius} (lines, circles, arcs, polyline segments; nearest solution to the picks)") + .interactive(|_| Ok(Box::new(TanCircleM::new(true)))), + CommandSpec::new("circle.ttt", "Tan, Tan, Tan", run_circle_ttt) + .menu(&["Draw", "Circle", "Tan, Tan, Tan"]) + .params("{h1, p1, h2, p2, h3, p3}") + .interactive(|_| Ok(Box::new(TanCircleM::new(false)))), + CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis) + .menu(&["Draw", "Ellipse", "Axis, End"]) + .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)") + .interactive(|_| Ok(super::draw::ellipse_machine(false, false))), + CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc) + .menu(&["Draw", "Ellipse", "Arc"]) + .params("{center, major: [dx,dy], ratio, start, end (degrees)} | {p1, p2, distance, start, end}") + .interactive(|_| Ok(super::draw::ellipse_machine(false, true))), + CommandSpec::new("divide", "Divide", run_divide) + .menu(&["Draw", "Point", "Divide"]) + .alias(&["div"]) + .params("{handle, segments, block?, align?: bool}") + .interactive(|_| Ok(Box::new(DivideM::new(false)))), + CommandSpec::new("measure", "Measure", run_measure) + .menu(&["Draw", "Point", "Measure"]) + .alias(&["me"]) + .params("{handle, length, block?, align?: bool, from?: [x,y] (end to start at)}") + .interactive(|_| Ok(Box::new(DivideM::new(true)))), + CommandSpec::new("revcloud", "Rectangular", run_revcloud) + .menu(&["Draw", "Revision Cloud", "Rectangular"]) + .params("{p1, p2} | {points: [...]} | {freehand: [...]} | {handle}, arcLength?, style?: normal|calligraphy, reverse?") + .interactive(|s| Ok(Box::new(RevcloudM::new(s, CloudKind::Rect)))), + CommandSpec::new("revcloud.polygonal", "Polygonal", run_revcloud) + .menu(&["Draw", "Revision Cloud", "Polygonal"]) + .params("{points: [[x,y],...], arcLength?}") + .interactive(|s| Ok(Box::new(RevcloudM::new(s, CloudKind::Poly)))), + CommandSpec::new("revcloud.freehand", "Freehand", run_revcloud) + .menu(&["Draw", "Revision Cloud", "Freehand"]) + .params("{freehand: [[x,y],...], arcLength?}") + .interactive(|s| Ok(Box::new(RevcloudM::new(s, CloudKind::Free)))), + CommandSpec::new("wipeout", "Wipeout", run_wipeout) + .menu(&["Draw", "Wipeout"]) + .params("{points: [[x,y],...]} | {handle (closed polyline), erase?: bool} | {frames: on|off|display}") + .interactive(|_| Ok(Box::new(WipeoutM::default()))), + CommandSpec::new("3dpoly", "3D Polyline", run_3dpoly) + .menu(&["Draw", "3D Polyline"]) + .alias(&["3p"]) + .params("{points: [[x,y,z?],...], closed?}") + .interactive(|_| Ok(Box::new(Poly3dM::default()))), + CommandSpec::new("mline", "Multiline", run_mline) + .menu(&["Draw", "Multiline"]) + .alias(&["ml"]) + .params("{points, scale?, justification?: top|zero|bottom, closed?} (two offset polylines)") + .interactive(|s| Ok(Box::new(MlineM::new(s)))), + CommandSpec::new("helix", "Helix", run_helix) + .menu(&["Draw", "Helix"]) + .params("{center, baseRadius, topRadius?, height?, turns?, ccw?} (3D polyline approximation)") + .interactive(|_| Ok(Box::new(HelixM::default()))), + CommandSpec::new("spline.cv", "Control Vertices", run_spline_cv) + .menu(&["Draw", "Spline", "Control Vertices"]) + .params("{control: [[x,y],...], degree?: 1..10 (default 3), closed?}") + .interactive(|_| Ok(Box::new(SplineCvM::default()))), + CommandSpec::new("centermark", "Center Mark", run_centermark) + .menu(&["Draw", "Center Mark"]) + .params("{handle | handles} (circles/arcs; lines on the CENTER linetype)") + .interactive(|_| Ok(Box::new(CenterM { first: None, mark: true }))), + CommandSpec::new("centerline", "Center Line", run_centerline) + .menu(&["Draw", "Center Line"]) + .params("{h1, h2} (two lines)") + .interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))), + ] +} + +fn added(h: Handle) -> Result { + Ok(json!({ "handle": h.hex() })) +} + +fn other(m: impl Into) -> EngineError { + EngineError::Other(m.into()) +} + +// ===================================================================================== +// Generic point/value sequences (arc and circle variants) +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ArcV { + /// The ARC command itself: three points, with options switching to the other variants. + ThreeP, + /// Start, end, then center. + Sec, + Sce, + Sca, + Scl, + Sea, + Sed, + Ser, + Cse, + Csa, + Csl, + CircleCd, + Circle2p, + Circle3p, +} + +/// What a step asks for. +#[derive(Clone, Copy, Debug)] +enum Ask { + Point, + /// Absolute angle from value `base` to the picked point, or a typed angle. + Angle(usize), + /// CCW angle at value `center` from the ray to value `from` to the ray to the picked point. + Sweep(usize, usize), + /// Distance from value `base`, or a typed distance (may be negative). + Dist(usize), +} + +#[derive(Clone, Copy, Debug)] +enum Val { + P(Vec2), + N(f64), +} + +impl Val { + fn p(self) -> Option { + if let Val::P(p) = self { Some(p) } else { None } + } + fn n(self) -> Option { + if let Val::N(n) = self { Some(n) } else { None } + } +} + +impl ArcV { + fn name(self) -> &'static str { + match self { + ArcV::CircleCd | ArcV::Circle2p | ArcV::Circle3p => "CIRCLE", + _ => "ARC", + } + } + fn steps(self) -> &'static [(&'static str, Ask)] { + const S: &str = "Specify start point of arc"; + const C: &str = "Specify center point of arc"; + const E: &str = "Specify end point of arc"; + const A: &str = "Specify included angle"; + const L: &str = "Specify length of chord"; + match self { + ArcV::ThreeP => &[(S, Ask::Point), ("Specify second point of arc", Ask::Point), (E, Ask::Point)], + ArcV::Sec => &[(S, Ask::Point), (E, Ask::Point), (C, Ask::Point)], + ArcV::Sce => &[(S, Ask::Point), (C, Ask::Point), (E, Ask::Point)], + ArcV::Sca => &[(S, Ask::Point), (C, Ask::Point), (A, Ask::Sweep(1, 0))], + ArcV::Scl => &[(S, Ask::Point), (C, Ask::Point), (L, Ask::Dist(0))], + ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))], + ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))], + ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))], + ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), (E, Ask::Point)], + ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))], + ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))], + ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))], + ArcV::Circle2p => { + &[("Specify first end point of circle's diameter", Ask::Point), ("Specify second end point of circle's diameter", Ask::Point)] + } + ArcV::Circle3p => &[ + ("Specify first point on circle", Ask::Point), + ("Specify second point on circle", Ask::Point), + ("Specify third point on circle", Ask::Point), + ], + } + } + fn build(self, v: &[Val]) -> Option { + let p = |i: usize| v.get(i).and_then(|x| x.p()); + let n = |i: usize| v.get(i).and_then(|x| x.n()); + let a = match self { + ArcV::ThreeP => Arc::from_3_points(p(0)?, p(1)?, p(2)?), + ArcV::Sec => Some(Arc::from_start_center_end(p(0)?, p(2)?, p(1)?)), + ArcV::Sce => Some(Arc::from_start_center_end(p(0)?, p(1)?, p(2)?)), + ArcV::Sca => curves::arc_sca(p(0)?, p(1)?, n(2)?), + ArcV::Scl => curves::arc_scl(p(0)?, p(1)?, n(2)?), + ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?), + ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)), + ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?), + ArcV::Cse => Some(Arc::from_start_center_end(p(1)?, p(0)?, p(2)?)), + ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?), + ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?), + ArcV::CircleCd => { + let d = n(1)?.abs(); + let c = p(0)?; + return (d > 1e-12 && d.is_finite()).then(|| circle(c, d / 2.0)); + } + ArcV::Circle2p => { + let c = Circle::from_2_points(p(0)?, p(1)?); + return (c.radius > 1e-12).then(|| circle(c.center, c.radius)); + } + ArcV::Circle3p => { + let c = Circle::from_3_points(p(0)?, p(1)?, p(2)?)?; + return Some(circle(c.center, c.radius)); + } + }?; + (a.radius > 1e-12 && a.radius.is_finite() && a.center.is_finite()).then(|| arc(&a)) + } +} + +impl ArcV { + /// Options at step `i` and the variant each one switches to (the values so far carry over). + fn alts(self, i: usize) -> &'static [(&'static str, ArcV)] { + match (self, i) { + (ArcV::ThreeP, 0) => &[("Center", ArcV::Cse)], + (ArcV::ThreeP, 1) => &[("Center", ArcV::Sce), ("End", ArcV::Sec)], + (ArcV::Sce, 2) => &[("Angle", ArcV::Sca), ("chord Length", ArcV::Scl)], + (ArcV::Cse, 2) => &[("Angle", ArcV::Csa), ("chord Length", ArcV::Csl)], + (ArcV::Sec, 2) => &[("Angle", ArcV::Sea), ("Direction", ArcV::Sed), ("Radius", ArcV::Ser)], + _ => &[], + } + } +} + +fn seq(v: ArcV) -> Box { + Box::new(SeqM { v, vals: Vec::new(), cont: None }) +} + +/// The full ARC prompt sequence (all options; Enter at the first prompt continues the last +/// line or arc). +pub(crate) fn arc_machine() -> Box { + seq(ArcV::ThreeP) +} + +struct SeqM { + v: ArcV, + vals: Vec, + /// ARC continuation: start point and tangent. + cont: Option<(Vec2, Vec2)>, +} + +impl SeqM { + fn ask(&self) -> Option<(&'static str, Ask)> { + self.v.steps().get(self.vals.len()).copied() + } + fn value_from_point(&self, ask: Ask, q: Vec2) -> Option { + let pt = |i: usize| self.vals.get(i).and_then(|x| x.p()); + Some(match ask { + Ask::Point => Val::P(q), + Ask::Angle(b) => Val::N(pt(b)?.angle_to(q)), + Ask::Sweep(c, f) => { + let c = pt(c)?; + Val::N(norm_angle(c.angle_to(q) - c.angle_to(pt(f)?))) + } + Ask::Dist(b) => Val::N(pt(b)?.dist(q)), + }) + } + fn base(&self) -> Option { + match self.ask()?.1 { + Ask::Point => self.vals.last().and_then(|v| v.p()), + Ask::Angle(b) | Ask::Dist(b) => self.vals.get(b).and_then(|v| v.p()), + Ask::Sweep(c, _) => self.vals.get(c).and_then(|v| v.p()), + } + } +} + +impl Interactive for SeqM { + fn name(&self) -> &'static str { + self.v.name() + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some((st, _)) = self.cont { + return Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base(st); + } + let kws: Vec<&str> = self.v.alts(self.vals.len()).iter().map(|a| a.0).collect(); + let p = match self.ask() { + Some((m, Ask::Point)) => Prompt::new(m, Accept::POINT).base_opt(self.base()), + Some((m, _)) => Prompt::new(m, Accept::POINT_OR_NUMBER).base_opt(self.base()), + None => Prompt::new("", Accept::default()), + }; + p.kw(&kws) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some((st, dir)) = self.cont { + return match i { + Input::Point(e) => { + let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?; + s.add_entity(arc(&a))?; + Ok(Step::Done) + } + Input::Enter => Ok(Step::Cancel), + _ => Err(other("Point required.")), + }; + } + if let Input::Keyword(k) = &i + && let Some((_, v)) = self.v.alts(self.vals.len()).iter().find(|a| a.0 == k) + { + self.v = *v; + return Ok(Step::Continue); + } + if i == Input::Enter && self.v == ArcV::ThreeP && self.vals.is_empty() { + self.cont = curves::last_curve_end(s); + if self.cont.is_none() { + s.echo("No line or arc to continue."); + return Ok(Step::Cancel); + } + return Ok(Step::Continue); + } + let Some((_, ask)) = self.ask() else { return Ok(Step::Done) }; + let val = match (&i, ask) { + (Input::Point(q), _) => self.value_from_point(ask, *q), + (Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => { + Some(Val::N(crate::units::parse_angle(t).ok_or_else(|| other("Requires a valid angle."))?)) + } + (Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)), + (Input::Enter, _) => return Ok(Step::Cancel), + _ => return Err(other("Point or value required.")), + }; + let Some(val) = val else { return Ok(Step::Continue) }; + self.vals.push(val); + if self.vals.len() < self.v.steps().len() { + return Ok(Step::Continue); + } + match self.v.build(&self.vals) { + Some(k) => { + if let EntityKind::Circle(c) = &k { + let r = c.radius; + s.add_entity(k)?; + s.doc_mut()?.header.set_f64("CIRCLERAD", r); + } else { + s.add_entity(k)?; + } + Ok(Step::Done) + } + None => { + self.vals.pop(); + Err(other("Invalid arc or circle for these values.")) + } + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if let Some((st, d)) = self.cont { + return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default(); + } + let Some((_, ask)) = self.ask() else { return Vec::new() }; + let Some(v) = self.value_from_point(ask, c) else { return Vec::new() }; + let mut vals = self.vals.clone(); + vals.push(v); + let mut out: Vec = self.base().map(|b| vec![line(b, c)]).unwrap_or_default(); + if vals.len() == self.v.steps().len() { + out.extend(self.v.build(&vals)); + } + out + } +} + +fn direction_param(p: &Value) -> Option { + match p.get("direction") { + Some(v) if v.is_array() || v.is_object() => point_value(v).filter(|d| d.len() > 1e-12).map(Vec2::angle), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(f64::to_radians), + None => None, + } +} + +fn arc_from_params(p: &Value) -> Result { + let st = point_param(p, "start"); + let c = point_param(p, "center"); + let e = point_param(p, "end"); + let ang = p.get("angle").and_then(Value::as_f64).filter(|v| v.is_finite()).map(f64::to_radians); + let len = p.get("length").and_then(Value::as_f64).filter(|v| v.is_finite()); + let rad = p.get("radius").and_then(Value::as_f64).filter(|v| v.is_finite()); + let dir = direction_param(p); + let a = match (st, c, e) { + (Some(st), Some(c), Some(e)) => Some(Arc::from_start_center_end(st, c, e)), + (Some(st), Some(c), None) => match (ang, len) { + (Some(a), _) => curves::arc_sca(st, c, a), + (None, Some(l)) => curves::arc_scl(st, c, l), + _ => return Err(bad("arc", "start+center needs `end`, `angle` or `length`")), + }, + (Some(st), None, Some(e)) => match (ang, dir, rad) { + (Some(a), _, _) => curves::arc_sea(st, e, a), + (None, Some(d), _) => curves::arc_sed(st, e, Vec2::from_angle(d)), + (None, None, Some(r)) => curves::arc_ser(st, e, r), + _ => return Err(bad("arc", "start+end needs `angle`, `direction` or `radius`")), + }, + _ => return Err(bad("arc", "give start with center and/or end (see params)")), + }; + let a = a.filter(|a| a.radius > 1e-12 && a.radius.is_finite()).ok_or_else(|| bad("arc", "these values do not define an arc"))?; + Ok(arc(&a)) +} + +fn run_arc_variant(s: &mut Session, p: &Value) -> Result { + let k = arc_from_params(p)?; + added(s.add_entity(k)?) +} + +fn run_arc_continue(s: &mut Session, p: &Value) -> Result { + let e = point_req("arc.continue", p, "end")?; + let (st, dir) = match (point_param(p, "start"), direction_param(p)) { + (Some(st), Some(d)) => (st, Vec2::from_angle(d)), + _ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?, + }; + let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?; + added(s.add_entity(arc(&a))?) +} + +#[derive(Default)] +struct ArcContinueM { + from: Option<(Vec2, Vec2)>, +} + +impl Interactive for ArcContinueM { + fn name(&self) -> &'static str { + "ARC" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.from = curves::last_curve_end(s); + if self.from.is_none() { + s.echo("No line or arc to continue."); + return Ok(Step::Cancel); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base_opt(self.from.map(|f| f.0)) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some((st, dir)) = self.from else { return Ok(Step::Cancel) }; + match i { + Input::Point(e) => { + let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?; + s.add_entity(arc(&a))?; + Ok(Step::Done) + } + Input::Enter => Ok(Step::Cancel), + _ => Err(other("Point required.")), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + self.from.and_then(|(st, d)| curves::arc_sed(st, c, d)).map(|a| vec![arc(&a)]).unwrap_or_default() + } +} + +fn run_circle_variant(s: &mut Session, p: &Value) -> Result { + let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) { + Circle::new(c, d / 2.0) + } else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { + match point_param(p, "p3") { + Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?, + None => Circle::from_2_points(a, b), + } + } else { + return Err(bad("circle", "give {center, diameter}, {p1, p2} or {p1, p2, p3}")); + }; + if !(c.radius > 1e-12 && c.radius.is_finite()) { + return Err(bad("circle", "radius must be positive")); + } + let h = s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); + added(h) +} + +// ---------------- tangent circles ---------------- + +fn tan_obj(s: &Session, h: Handle, pick: Vec2) -> Result { + let e = curves::entity(s, h)?; + TanObj::from_kind(&e.kind, pick).ok_or_else(|| other("Select a line, circle, arc or polyline segment.")) +} + +fn pick_pair(cmd: &str, p: &Value, hk: &str, pk: &str) -> Result<(Handle, Vec2)> { + let h = curves::handle_param(p, hk).ok_or_else(|| bad(cmd, format!("`{hk}` is required")))?; + let q = point_req(cmd, p, pk)?; + Ok((h, q)) +} + +fn run_circle_ttr(s: &mut Session, p: &Value) -> Result { + let (h1, p1) = pick_pair("circle.ttr", p, "h1", "p1")?; + let (h2, p2) = pick_pair("circle.ttr", p, "h2", "p2")?; + let r = f64_req("circle.ttr", p, "radius")?; + let c = curves::ttr(tan_obj(s, h1, p1)?, p1, tan_obj(s, h2, p2)?, p2, r).ok_or_else(|| other("Circle does not exist."))?; + let h = s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius); + added(h) +} + +fn run_circle_ttt(s: &mut Session, p: &Value) -> Result { + let (h1, p1) = pick_pair("circle.ttt", p, "h1", "p1")?; + let (h2, p2) = pick_pair("circle.ttt", p, "h2", "p2")?; + let (h3, p3) = pick_pair("circle.ttt", p, "h3", "p3")?; + let objs = [(tan_obj(s, h1, p1)?, p1), (tan_obj(s, h2, p2)?, p2), (tan_obj(s, h3, p3)?, p3)]; + let c = curves::ttt(objs).ok_or_else(|| other("Circle does not exist."))?; + added(s.add_entity(circle(c.center, c.radius))?) +} + +/// TTR (two picks + radius) or TTT (three picks). Also used by CIRCLE's `Ttr` option. +pub(crate) struct TanCircleM { + radius_mode: bool, + picks: Vec<(TanObj, Vec2)>, +} + +impl TanCircleM { + pub(crate) fn new(radius_mode: bool) -> Self { + TanCircleM { radius_mode, picks: Vec::new() } + } +} + +impl Interactive for TanCircleM { + fn name(&self) -> &'static str { + "CIRCLE" + } + fn prompt(&self, s: &Session) -> Prompt { + let n = self.picks.len(); + if self.radius_mode { + match n { + 0 => Prompt::new("Specify point on object for first tangent of circle", Accept::POINT), + 1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT), + _ => { + let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); + let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)); + if r > 0.0 { p.default(format!("{r:.4}")) } else { p } + } + } + } else { + let m = ["Specify first point on circle: _tan to", "Specify second point on circle: _tan to", "Specify third point on circle: _tan to"]; + Prompt::new(m.get(n).copied().unwrap_or(""), Accept::POINT) + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let need = if self.radius_mode { 2 } else { 3 }; + if self.picks.len() < need { + return match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let o = tan_obj(s, h, p)?; + self.picks.push((o, p)); + if !self.radius_mode && self.picks.len() == 3 { + let (Some(a), Some(b), Some(c)) = (self.picks.first(), self.picks.get(1), self.picks.get(2)) else { + return Ok(Step::Cancel); + }; + let c = curves::ttt([*a, *b, *c]).ok_or_else(|| { + self.picks.clear(); + other("Circle does not exist.") + })?; + s.add_entity(circle(c.center, c.radius))?; + return Ok(Step::Done); + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Cancel), + _ => Err(other("Select an object.")), + }; + } + let r = match i { + Input::Point(p) => self.picks.last().map(|x| x.1.dist(p)).unwrap_or(0.0), + Input::Text(t) => number(&t).ok_or_else(|| other("Requires numeric distance or second point."))?, + Input::Enter => s.doc()?.header.f64("CIRCLERAD", 0.0), + _ => return Ok(Step::Continue), + }; + if r.is_nan() || r <= 0.0 { + return Err(other("Value must be positive and nonzero.")); + } + let ((o1, p1), (o2, p2)) = match (self.picks.first(), self.picks.get(1)) { + (Some(a), Some(b)) => (*a, *b), + _ => return Ok(Step::Cancel), + }; + match curves::ttr(o1, p1, o2, p2, r) { + Some(c) => { + s.add_entity(circle(c.center, c.radius))?; + s.doc_mut()?.header.set_f64("CIRCLERAD", r); + Ok(Step::Done) + } + None => { + s.echo("Circle does not exist."); + Ok(Step::Done) + } + } + } +} + +// ---------------- ellipse entries ---------------- + +fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option { + let c = p1.mid(p2); + let m = p2 - c; + let ml = m.len(); + let half = half.abs(); + if ml < 1e-12 || half < 1e-12 || !half.is_finite() { + return None; + } + Some(if half > ml { Ellipse::full(c, m.normalized().perp() * half, ml / half) } else { Ellipse::full(c, m, half / ml) }) +} + +fn axis_ellipse_params(cmd: &str, p: &Value) -> Result { + let p1 = point_req(cmd, p, "p1")?; + let p2 = point_req(cmd, p, "p2")?; + let half = match point_param(p, "p3") { + Some(p3) => { + let ax = Line::new(p1, p2); + ax.project(p3).dist(p3) + } + None => f64_req(cmd, p, "distance")?, + }; + ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse")) +} + +fn ellipse_kind(e: &Ellipse) -> EntityKind { + EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end }) +} + +fn run_ellipse_axis(s: &mut Session, p: &Value) -> Result { + let e = axis_ellipse_params("ellipse.axis", p)?; + added(s.add_entity(ellipse_kind(&e))?) +} + +fn run_ellipse_arc(s: &mut Session, p: &Value) -> Result { + let mut e = if let Some(c) = point_param(p, "center") { + let m = point_req("ellipse.arc", p, "major")?; + let ratio = f64_req("ellipse.arc", p, "ratio")?; + if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 { + return Err(bad("ellipse.arc", "ratio must be in (0, 1] and major non-zero")); + } + Ellipse::full(c, m, ratio) + } else { + axis_ellipse_params("ellipse.arc", p)? + }; + // Angles are measured from the major axis (like the prompts); convert to parameters. + let to_param = |deg: f64| { + let a = deg.to_radians(); + let ge = Ellipse { start: 0.0, end: TAU, ..e }; + ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) + }; + let st = to_param(f64_req("ellipse.arc", p, "start")?); + let en = to_param(f64_req("ellipse.arc", p, "end")?); + e.start = st; + e.end = en; + added(s.add_entity(ellipse_kind(&e))?) +} + +// ===================================================================================== +// DIVIDE / MEASURE +// ===================================================================================== + +fn chain_of(s: &Session, h: Handle) -> Result { + let e = curves::entity(s, h)?; + Chain::of(&e.kind).ok_or_else(|| other("Cannot divide/measure that object.")) +} + +fn place_markers(s: &mut Session, at: &[(Vec2, Vec2)], block: Option<&str>, align: bool) -> Result> { + let mut out = Vec::new(); + for (p, t) in at { + let h = match block { + Some(b) => super::blocks::insert(s, b, *p, 1.0, if align { t.angle() } else { 0.0 }, &serde_json::Map::new())?, + None => s.add_entity(EntityKind::Point(Point { p: v3(*p), angle: 0.0 }))?, + }; + out.push(h.hex()); + } + Ok(out) +} + +fn check_block(s: &Session, b: Option<&str>) -> Result<()> { + if let Some(b) = b + && s.doc()?.block(b).is_none() + { + return Err(other(format!("Block \"{b}\" not found."))); + } + Ok(()) +} + +pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> { + let l = c.len(); + let range = if c.closed { 0..n } else { 1..n }; + range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect() +} + +pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> { + let l = c.len(); + let mut out = Vec::new(); + let mut k = 1usize; + while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN { + let d = k as f64 * step; + out.extend(c.at_length(d)); + k += 1; + } + out +} + +fn run_divide(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("divide", "`handle` is required"))?; + let n = p.get("segments").and_then(Value::as_u64).ok_or_else(|| bad("divide", "`segments` (2..32767) is required"))?; + if !(2..=32767).contains(&n) { + return Err(bad("divide", "segments must be 2..32767")); + } + let block = str_param(p, "block").map(str::to_string); + check_block(s, block.as_deref())?; + let c = chain_of(s, h)?; + let pts = divide_points(&c, n as usize); + let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; + Ok(json!({ "handles": hs })) +} + +fn run_measure(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("measure", "`handle` is required"))?; + let step = f64_req("measure", p, "length")?; + let mut c = chain_of(s, h)?; + if step.is_nan() || step <= 0.0 || c.len() / step > MAX_GEN as f64 { + return Err(bad("measure", "length must be positive (and not tiny)")); + } + let block = str_param(p, "block").map(str::to_string); + check_block(s, block.as_deref())?; + if let Some(from) = point_param(p, "from") + && !c.closed + && let (Some(a), Some(b)) = (c.start(), c.end()) + && from.dist(b) < from.dist(a) + { + c = c.reversed(); + } + let pts = measure_points(&c, step); + let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; + Ok(json!({ "handles": hs })) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DivPhase { + Object, + Value, + BlockName, + Align, +} + +struct DivideM { + measure: bool, + obj: Option<(Handle, Vec2)>, + phase: DivPhase, + block: Option, + align: bool, +} + +impl DivideM { + fn new(measure: bool) -> Self { + DivideM { measure, obj: None, phase: DivPhase::Object, block: None, align: true } + } +} + +impl Interactive for DivideM { + fn name(&self) -> &'static str { + if self.measure { "MEASURE" } else { "DIVIDE" } + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.phase { + DivPhase::Object => Prompt::new(if self.measure { "Select object to measure" } else { "Select object to divide" }, Accept::POINT), + DivPhase::Value => { + let m = if self.measure { "Specify length of segment" } else { "Enter the number of segments" }; + let p = Prompt::new(m, if self.measure { Accept::POINT_OR_NUMBER } else { Accept::NUMBER }); + let p = if self.measure { p.base_opt(self.obj.map(|o| o.1)) } else { p }; + if self.block.is_some() { p } else { p.kw(&["Block"]) } + } + DivPhase::BlockName => Prompt::new("Enter name of block to insert", Accept::TEXT), + DivPhase::Align => Prompt::new("Align block with object?", curves::KW).kw(&["Yes", "No"]).default("Y"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (DivPhase::Object, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + chain_of(s, h)?; + self.obj = Some((h, p)); + self.phase = DivPhase::Value; + Ok(Step::Continue) + } + (DivPhase::Value, Input::Keyword(k)) if k == "Block" => { + self.phase = DivPhase::BlockName; + Ok(Step::Continue) + } + (DivPhase::BlockName, Input::Text(t) | Input::Keyword(t)) => { + let name = t.trim().to_string(); + check_block(s, Some(&name))?; + self.block = Some(name); + self.phase = DivPhase::Align; + Ok(Step::Continue) + } + (DivPhase::Align, Input::Keyword(k) | Input::Text(k)) => { + self.align = !k.trim().to_ascii_lowercase().starts_with('n'); + self.phase = DivPhase::Value; + Ok(Step::Continue) + } + (DivPhase::Align, Input::Enter) => { + self.phase = DivPhase::Value; + Ok(Step::Continue) + } + (DivPhase::Value, inp @ (Input::Text(_) | Input::Point(_))) => { + let Some((h, pick)) = self.obj else { return Ok(Step::Cancel) }; + let v = match inp { + Input::Text(t) => number(&t).ok_or_else(|| other("Requires a number."))?, + Input::Point(q) => pick.dist(q), + _ => return Ok(Step::Continue), + }; + let mut params = json!({ "handle": h.hex(), "align": self.align, "from": [pick.x, pick.y] }); + if let Some(b) = &self.block { + params["block"] = json!(b); + } + if self.measure { + params["length"] = json!(v); + run_measure(s, ¶ms)?; + } else { + if !(2.0..=32767.0).contains(&v) || v.fract() != 0.0 { + return Err(other("Requires an integer between 2 and 32767.")); + } + params["segments"] = json!(v as u64); + run_divide(s, ¶ms)?; + } + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Err(other("Invalid input.")), + } + } +} + +// ===================================================================================== +// REVCLOUD +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CloudKind { + Rect, + Poly, + Free, +} + +fn default_arc_len(s: &Session) -> f64 { + let d = s.doc().ok(); + let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); + d.map(|d| d.header.f64("REVCLOUDARCLEN", if metric { 15.0 } else { 0.5 })).unwrap_or(0.5).max(1e-6) +} + +/// Bulged closed polyline vertices for a revision cloud around `boundary`. +pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, reverse: bool) -> Option> { + let mut pts: Vec = Vec::new(); + for p in boundary { + if p.is_finite() && pts.last().is_none_or(|l| !l.near(*p, 1e-9)) { + pts.push(*p); + } + } + while pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) { + pts.pop(); + } + if pts.len() < 3 || shoelace(&pts).abs() < 1e-12 { + return None; + } + if shoelace(&pts) < 0.0 { + pts.reverse(); + } + let n = pts.len(); + let perim: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum(); + if !(arc_len.is_finite() && arc_len > 0.0) { + return None; + } + let arc_len = arc_len.max(perim / MAX_GEN as f64); + let mut out: Vec = Vec::new(); + if per_edge { + for i in 0..n { + let (Some(a), Some(b)) = (pts.get(i).copied(), pts.get((i + 1) % n).copied()) else { continue }; + let k = ((a.dist(b) / arc_len).round() as usize).clamp(1, MAX_GEN); + for j in 0..k { + out.push(a.lerp(b, j as f64 / k as f64)); + } + } + } else { + let chain = Chain { + segs: (0..n).filter_map(|i| Some(cadcraft_geom::Segment::Line(Line::new(*pts.get(i)?, *pts.get((i + 1) % n)?)))).collect(), + closed: true, + }; + let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN); + for j in 0..k { + out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0)); + } + } + let b = if reverse { -0.5 } else { 0.5 }; + Some(out.into_iter().map(|p| PolyVertex::with_bulge(p, b)).collect()) +} + +fn object_outline(s: &Session, h: Handle) -> Result> { + let e = curves::entity(s, h)?; + let tol = 1e-3; + let pts = match &e.kind { + EntityKind::LwPolyline(p) if p.closed => Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(tol), + EntityKind::Circle(c) => { + let mut v = Vec::new(); + Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-3, &mut v); + v + } + EntityKind::Ellipse(el) => { + let mut v = Vec::new(); + Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end } + .tessellate(el.major.xy().len() * 1e-3, &mut v); + v + } + EntityKind::Spline(sp) if sp.closed => sp.tessellate(tol), + _ => return Err(other("Select a closed polyline, circle, ellipse or closed spline.")), + }; + Ok(pts) +} + +fn cloud_entity(vs: Vec, calligraphy: bool, arc_len: f64) -> EntityKind { + let vs = if calligraphy { vs.into_iter().map(|v| PolyVertex { start_width: 0.0, end_width: arc_len * 0.15, ..v }).collect() } else { vs }; + lwpoly(vs, true) +} + +fn run_revcloud(s: &mut Session, p: &Value) -> Result { + let arc_len = p.get("arcLength").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or_else(|| default_arc_len(s)); + let reverse = bool_or(p, "reverse", false); + let calligraphy = str_param(p, "style").is_some_and(|x| x.eq_ignore_ascii_case("calligraphy")); + let (pts, per_edge, src) = if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { + (rect_vertices(a, b).into_iter().map(|v| v.p).collect(), true, None) + } else if let Some(pts) = points_param(p, "points") { + (pts, true, None) + } else if let Some(pts) = points_param(p, "freehand") { + (pts, false, None) + } else if let Some(h) = curves::handle_param(p, "handle") { + (object_outline(s, h)?, false, Some(h)) + } else { + return Err(bad("revcloud", "give {p1, p2}, {points}, {freehand} or {handle}")); + }; + if pts.len() > MAX_GEN { + return Err(bad("revcloud", "too many points")); + } + let vs = cloud_vertices(&pts, arc_len, per_edge, reverse).ok_or_else(|| bad("revcloud", "the boundary needs an area"))?; + let h = s.add_entity(cloud_entity(vs, calligraphy, arc_len))?; + if let Some(src) = src { + let common = curves::entity(s, src)?.common; + let d = s.doc_mut()?; + d.modify_entity(h, |e| e.common = common)?; + d.remove_entity(src); + } + added(h) +} + +struct RevcloudM { + kind: CloudKind, + pts: Vec, + arc_len: f64, + asking_arc: bool, + object: bool, + calligraphy: bool, + asking_style: bool, +} + +impl RevcloudM { + fn new(s: &Session, kind: CloudKind) -> Self { + RevcloudM { kind, pts: Vec::new(), arc_len: default_arc_len(s), asking_arc: false, object: false, calligraphy: false, asking_style: false } + } + fn boundary(&self, extra: Option) -> Vec { + let mut pts = self.pts.clone(); + pts.extend(extra); + if self.kind == CloudKind::Rect + && let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied()) + { + return rect_vertices(a, b).into_iter().map(|v| v.p).collect(); + } + pts + } + fn finish(&mut self, s: &mut Session) -> Result { + let b = self.boundary(None); + match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) { + Some(vs) => { + s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?; + s.echo("Revision cloud finished."); + Ok(Step::Done) + } + None => Err(other("The revision cloud needs at least three points enclosing an area.")), + } + } +} + +impl Interactive for RevcloudM { + fn name(&self) -> &'static str { + "REVCLOUD" + } + fn begin(&mut self, s: &mut Session) -> Result { + let t = match self.kind { + CloudKind::Rect => "Rectangular", + CloudKind::Poly => "Polygonal", + CloudKind::Free => "Freehand", + }; + s.echo(format!("Minimum arc length: {0:.4} Maximum arc length: {0:.4} Style: Normal Type: {t}", self.arc_len)); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking_arc { + return Prompt::new("Specify minimum length of arc", Accept::NUMBER).default(format!("{:.4}", self.arc_len)); + } + if self.asking_style { + return Prompt::new("Select arc style", curves::KW).kw(&["Normal", "Calligraphy"]).default("Normal"); + } + if self.object { + return Prompt::new("Select object", Accept::POINT); + } + let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"]; + match (self.kind, self.pts.len()) { + (CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"), + (CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()), + (CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"), + (CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), + (CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + (CloudKind::Free, 0) => Prompt::new("Specify first point", Accept::POINT).kw(first_kw).default("Object"), + (CloudKind::Free, _) => Prompt::new("Guide crosshairs along cloud path", Accept::POINT).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking_arc { + if let Input::Text(t) = &i { + let v = number(t).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive value."))?; + self.arc_len = v; + s.doc_mut()?.header.set_f64("REVCLOUDARCLEN", v); + } + self.asking_arc = false; + return Ok(Step::Continue); + } + if self.asking_style { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.calligraphy = k.to_ascii_lowercase().starts_with('c'); + } + self.asking_style = false; + return Ok(Step::Continue); + } + if self.object { + let Input::Point(p) = i else { return if i == Input::Enter { Ok(Step::Cancel) } else { Ok(Step::Continue) } }; + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let pts = object_outline(s, h)?; + let vs = cloud_vertices(&pts, self.arc_len, false, false).ok_or_else(|| other("The object has no area."))?; + let nh = s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?; + let common = curves::entity(s, h)?.common; + let d = s.doc_mut()?; + d.modify_entity(nh, |e| e.common = common)?; + d.remove_entity(h); + s.echo("Revision cloud finished."); + return Ok(Step::Done); + } + match i { + Input::Keyword(k) => { + match k.as_str() { + "Arc length" => self.asking_arc = true, + "Object" => self.object = true, + "Rectangular" => self.kind = CloudKind::Rect, + "Polygonal" => self.kind = CloudKind::Poly, + "Freehand" => self.kind = CloudKind::Free, + "Style" => self.asking_style = true, + "Undo" => { + self.pts.pop(); + } + _ => {} + } + Ok(Step::Continue) + } + Input::Point(p) => { + self.pts.push(p); + if self.kind == CloudKind::Rect && self.pts.len() == 2 { + return self.finish(s); + } + if self.pts.len() > MAX_GEN { + return self.finish(s); + } + Ok(Step::Continue) + } + Input::Enter if self.pts.is_empty() => { + self.object = true; + Ok(Step::Continue) + } + Input::Enter => self.finish(s), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() || self.object { + return Vec::new(); + } + let b = self.boundary(Some(c)); + match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) { + Some(vs) if vs.len() < 2000 => vec![lwpoly(vs, true)], + _ => { + let mut pts = self.pts.clone(); + pts.push(c); + vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), false)] + } + } + } +} + +// ===================================================================================== +// WIPEOUT +// ===================================================================================== + +fn set_frames(s: &mut Session, mode: &str) -> Result { + let v = match mode.trim().to_ascii_lowercase().as_str() { + "on" | "1" => 1, + "off" | "0" => 0, + "display" | "d" | "2" | "display but not plot" => 2, + _ => return Err(other("Frames mode must be ON, OFF or Display.")), + }; + s.doc_mut()?.header.set_i64("WIPEOUTFRAME", v); + Ok(v) +} + +fn wipeout_from_polyline(s: &mut Session, h: Handle, erase: bool) -> Result { + let e = curves::entity(s, h)?; + let EntityKind::LwPolyline(p) = &e.kind else { return Err(other("Select a closed polyline.")) }; + if !p.closed || p.vertices.len() < 3 { + return Err(other("The polyline must be closed with at least three vertices.")); + } + if p.vertices.iter().any(|v| v.bulge.abs() > 1e-12) { + return Err(other("Polylines with arc segments cannot be used for wipeouts.")); + } + let nh = s.add_entity(EntityKind::Wipeout(Wipeout { boundary: p.vertices.iter().map(|v| v.p).collect() }))?; + if erase { + s.doc_mut()?.remove_entity(h); + } + Ok(nh) +} + +fn run_wipeout(s: &mut Session, p: &Value) -> Result { + if let Some(f) = str_param(p, "frames") { + let v = set_frames(s, f)?; + return Ok(json!({ "WIPEOUTFRAME": v })); + } + if let Some(h) = curves::handle_param(p, "handle") { + return added(wipeout_from_polyline(s, h, bool_or(p, "erase", false))?); + } + let pts = points_param(p, "points").ok_or_else(|| bad("wipeout", "`points` (3 or more) or `handle` is required"))?; + if pts.len() < 3 || pts.len() > MAX_GEN || shoelace(&pts).abs() < 1e-12 { + return Err(bad("wipeout", "need 3+ points enclosing an area")); + } + added(s.add_entity(EntityKind::Wipeout(Wipeout { boundary: pts }))?) +} + +#[derive(Default)] +struct WipeoutM { + pts: Vec, + phase: u8, // 0 points, 1 select polyline, 2 erase?, 3 frames + poly: Option, +} + +impl Interactive for WipeoutM { + fn name(&self) -> &'static str { + "WIPEOUT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.phase, self.pts.len()) { + (1, _) => Prompt::new("Select a closed polyline", Accept::POINT), + (2, _) => Prompt::new("Erase polyline?", curves::KW).kw(&["Yes", "No"]).default("No"), + (3, _) => Prompt::new("Enter mode", curves::KW).kw(&["ON", "OFF", "Display but not plot"]).default("ON"), + (_, 0) => Prompt::new("Specify first point", Accept::POINT).kw(&["Frames", "Polyline"]).default("Polyline"), + (_, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), + (_, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (1, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let e = curves::entity(s, h)?; + if !matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) { + return Err(other("Select a closed polyline.")); + } + self.poly = Some(h); + self.phase = 2; + Ok(Step::Continue) + } + (2, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => { + let erase = matches!(&inp, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y')); + let h = self.poly.ok_or_else(|| other("No polyline."))?; + wipeout_from_polyline(s, h, erase)?; + Ok(Step::Done) + } + (3, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => { + let m = match &inp { + Input::Keyword(k) | Input::Text(k) => k.clone(), + _ => "on".into(), + }; + let m = if m.to_ascii_lowercase().starts_with('d') { "display".to_string() } else { m }; + set_frames(s, &m)?; + Ok(Step::Done) + } + (0, Input::Keyword(k)) => { + match k.as_str() { + "Frames" => self.phase = 3, + "Polyline" => self.phase = 1, + "Undo" => { + self.pts.pop(); + } + _ => {} + } + Ok(Step::Continue) + } + (0, Input::Point(p)) => { + if self.pts.len() < MAX_GEN { + self.pts.push(p); + } + Ok(Step::Continue) + } + (0, Input::Enter) if self.pts.is_empty() => { + self.phase = 1; + Ok(Step::Continue) + } + (0, Input::Enter) => { + if self.pts.len() < 3 || shoelace(&self.pts).abs() < 1e-12 { + return Err(other("A wipeout needs three or more points enclosing an area.")); + } + s.add_entity(EntityKind::Wipeout(Wipeout { boundary: self.pts.clone() }))?; + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.phase != 0 || self.pts.is_empty() { + return Vec::new(); + } + let mut pts = self.pts.clone(); + pts.push(c); + vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), true)] + } +} + +// ===================================================================================== +// XLINE with Hor/Ver/Ang/Bisect/Offset +// ===================================================================================== + +fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result { + let d = dir.normalized(); + if d == Vec2::ZERO || !base.is_finite() { + return Err(other("Invalid direction.")); + } + s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) })) +} + +/// A line-like object for XLINE Offset/Reference: (base, direction). +fn linear_of(s: &Session, h: Handle) -> Result<(Vec2, Vec2)> { + let e = curves::entity(s, h)?; + match &e.kind { + EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())), + EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())), + EntityKind::LwPolyline(p) => { + let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments(); + match segs.first() { + Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())), + _ => Err(other("Select a line object.")), + } + } + _ => Err(other("Select a line object.")), + } +} + +fn bisect_dir(v: Vec2, a: Vec2, b: Vec2) -> Option { + let u1 = (a - v).normalized(); + let u2 = (b - v).normalized(); + if u1 == Vec2::ZERO || u2 == Vec2::ZERO { + return None; + } + let s = u1 + u2; + if s.len() < 1e-12 { Some(u1.perp()) } else { Some(s.normalized()) } +} + +fn offset_xline(base: Vec2, dir: Vec2, dist: f64, side: Vec2) -> (Vec2, Vec2) { + let n = dir.perp(); + let sgn = if n.dot(side - base) >= 0.0 { 1.0 } else { -1.0 }; + (base + n * (dist * sgn), dir) +} + +pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result { + if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "start"), point_param(p, "end")) { + let d = bisect_dir(v, a, b).ok_or_else(|| bad("xline", "degenerate angle"))?; + return added(add_xline(s, v, d)?); + } + if let Some(h) = curves::handle_param(p, "handle") { + let (base, dir) = linear_of(s, h)?; + let (nb, nd) = if let Some(t) = point_param(p, "through") { + (t, dir) + } else { + let dist = f64_req("xline", p, "distance")?; + let side = point_req("xline", p, "side")?; + offset_xline(base, dir, dist, side) + }; + return added(add_xline(s, nb, nd)?); + } + let b = point_param(p, "base").or_else(|| point_param(p, "through")).ok_or_else(|| bad("xline", "`base` is required"))?; + let dir = if bool_or(p, "hor", false) { + Vec2::X + } else if bool_or(p, "ver", false) { + Vec2::Y + } else if let (Some(t), Some(_)) = (point_param(p, "through"), point_param(p, "base")) { + t - b + } else { + Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians()) + }; + if dir.len() < 1e-12 { + return Err(bad("xline", "through point equals base")); + } + added(add_xline(s, b, dir)?) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum XMode { + #[default] + Two, + Fixed(f64), + AngAsk, + RefSelect, + RefAngle(f64), + Bisect, + OffDist, + OffSelect, + OffSide, +} + +#[derive(Default)] +pub(crate) struct XlineM2 { + mode: XMode, + base: Option, + bis: Vec, + offset: Option, // None = Through + line: Option<(Vec2, Vec2)>, +} + +impl Interactive for XlineM2 { + fn name(&self) -> &'static str { + "XLINE" + } + fn prompt(&self, s: &Session) -> Prompt { + match self.mode { + XMode::Two => match self.base { + None => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]), + Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), + }, + XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT), + XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]), + XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT), + XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"), + XMode::Bisect => match self.bis.len() { + 0 => Prompt::new("Specify angle vertex point", Accept::POINT), + 1 => Prompt::new("Specify angle start point", Accept::POINT).base_opt(self.bis.first().copied()), + _ => Prompt::new("Specify angle end point", Accept::POINT).base_opt(self.bis.first().copied()), + }, + XMode::OffDist => { + let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0); + Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 { + "Through".into() + } else { + format!("{d:.4}") + }) + } + XMode::OffSelect => Prompt::new("Select a line object", Accept::POINT), + XMode::OffSide => Prompt::new(if self.offset.is_some() { "Specify side to offset" } else { "Specify through point" }, Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.mode, i) { + (_, Input::Enter) if matches!(self.mode, XMode::OffDist) => { + let d = s.doc()?.header.f64("OFFSETDIST", 1.0); + self.offset = (d >= 0.0).then_some(d); + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Done), + (XMode::Two, Input::Keyword(k)) => { + self.mode = match k.as_str() { + "Hor" => XMode::Fixed(0.0), + "Ver" => XMode::Fixed(std::f64::consts::FRAC_PI_2), + "Ang" => XMode::AngAsk, + "Bisect" => XMode::Bisect, + _ => XMode::OffDist, + }; + Ok(Step::Continue) + } + (XMode::Two, Input::Point(p)) => { + match self.base { + None => self.base = Some(p), + Some(b) => { + if !b.near(p, 1e-12) { + add_xline(s, b, p - b)?; + } + } + } + Ok(Step::Continue) + } + (XMode::Fixed(a), Input::Point(p)) => { + add_xline(s, p, Vec2::from_angle(a))?; + Ok(Step::Continue) + } + (XMode::AngAsk, Input::Keyword(_)) => { + self.mode = XMode::RefSelect; + Ok(Step::Continue) + } + (XMode::AngAsk, Input::Text(t)) => { + let a = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?; + self.mode = XMode::Fixed(a); + Ok(Step::Continue) + } + (XMode::RefSelect, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let (_, d) = linear_of(s, h)?; + self.mode = XMode::RefAngle(d.angle()); + Ok(Step::Continue) + } + (XMode::RefAngle(base), Input::Text(t)) => { + let a = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?; + self.mode = XMode::Fixed(base + a); + Ok(Step::Continue) + } + (XMode::Bisect, Input::Point(p)) => { + if self.bis.len() < 2 { + self.bis.push(p); + } else if let (Some(v), Some(a)) = (self.bis.first().copied(), self.bis.get(1).copied()) { + let d = bisect_dir(v, a, p).ok_or_else(|| other("Invalid angle."))?; + add_xline(s, v, d)?; + } + Ok(Step::Continue) + } + (XMode::OffDist, Input::Keyword(_)) => { + self.offset = None; + s.doc_mut()?.header.set_f64("OFFSETDIST", -1.0); + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + (XMode::OffDist, Input::Text(t)) => { + let d = number(&t).filter(|d| *d >= 0.0).ok_or_else(|| other("Requires a non-negative distance."))?; + self.offset = Some(d); + s.doc_mut()?.header.set_f64("OFFSETDIST", d); + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + (XMode::OffSelect, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + self.line = Some(linear_of(s, h)?); + self.mode = XMode::OffSide; + Ok(Step::Continue) + } + (XMode::OffSide, Input::Point(p)) => { + let (b, d) = self.line.ok_or_else(|| other("No line selected."))?; + let (nb, nd) = match self.offset { + Some(dist) => offset_xline(b, d, dist, p), + None => (p, d), + }; + add_xline(s, nb, nd)?; + self.mode = XMode::OffSelect; + Ok(Step::Continue) + } + _ => Err(other("Invalid input.")), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let xl = |b: Vec2, d: Vec2| { + let d = d.normalized(); + (d != Vec2::ZERO).then(|| EntityKind::XLine(RayLine { base: v3(b), dir: v3(d) })) + }; + match self.mode { + XMode::Two => self.base.and_then(|b| xl(b, c - b)).into_iter().collect(), + XMode::Fixed(a) => xl(c, Vec2::from_angle(a)).into_iter().collect(), + XMode::Bisect => match self.bis.as_slice() { + [v, a] => bisect_dir(*v, *a, c).and_then(|d| xl(*v, d)).into_iter().collect(), + _ => Vec::new(), + }, + XMode::OffSide => self + .line + .and_then(|(b, d)| { + let (nb, nd) = match self.offset { + Some(dist) => offset_xline(b, d, dist, c), + None => (c, d), + }; + xl(nb, nd) + }) + .into_iter() + .collect(), + _ => Vec::new(), + } + } +} + +// ===================================================================================== +// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness +// ===================================================================================== + +fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind { + let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer); + let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect(); + let mut k = lwpoly(vs, true); + if let EntityKind::LwPolyline(pl) = &mut k { + pl.const_width = width.max(0.0); + pl.elevation = elevation; + } + k +} + +pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { + let a = point_req("rectang", p, "p1")?; + let rot = f64_or(p, "rotation", 0.0).to_radians(); + 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))) + .map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 })) + .unwrap_or((0.0, 0.0)); + let fillet = f64_or(p, "fillet", 0.0); + let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) { + let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; + let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; + let (sx, sy) = match point_param(p, "p2") { + Some(q) => { + let loc = (q - a).rotate(-rot); + (if loc.x < 0.0 { -1.0 } else { 1.0 }, if loc.y < 0.0 { -1.0 } else { 1.0 }) + } + None => (1.0, 1.0), + }; + (l.abs() * sx, wd.abs() * sy) + } else if let Some(area) = p.get("area").and_then(Value::as_f64).filter(|v| v.is_finite()) { + if area <= 0.0 { + return Err(bad("rectang", "area must be positive")); + } + match (p.get("length").and_then(Value::as_f64), p.get("breadth").and_then(Value::as_f64)) { + (Some(l), _) if l.is_finite() && l > 0.0 => (l, area / l), + (_, Some(b)) if b.is_finite() && b > 0.0 => (area / b, b), + _ => return Err(bad("rectang", "area needs `length` or `breadth`")), + } + } else { + let b = point_req("rectang", p, "p2")?; + let loc = (b - a).rotate(-rot); + (loc.x, loc.y) + }; + if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() { + return Err(bad("rectang", "rectangle has no area")); + } + let k = rect_kind(a, w, h, rot, fillet, ch, f64_or(p, "width", 0.0), f64_or(p, "elevation", 0.0)); + let hd = s.add_entity(k)?; + let th = f64_or(p, "thickness", 0.0); + if th != 0.0 { + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; + } + added(hd) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum RAsk { + Chamfer1, + Chamfer2, + Fillet, + Width, + Elevation, + Thickness, + Area, + AreaBasis, + AreaLength, + AreaWidth, + DimL, + DimW, + Rotation, + RotPick1, + RotPick2(Vec2), +} + +#[derive(Default)] +pub(crate) struct RectM2 { + first: Option, + fillet: f64, + chamfer: (f64, f64), + width: f64, + elevation: f64, + thickness: f64, + rotation: f64, + dims: Option<(f64, f64)>, + area: f64, + asking: Option, +} + +impl RectM2 { + fn make(&self, s: &mut Session, w: f64, h: f64) -> Result { + let Some(a) = self.first else { return Ok(Step::Cancel) }; + if w.abs() < 1e-12 || h.abs() < 1e-12 { + return Err(other("The rectangle has no area.")); + } + let hd = s.add_entity(rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; + if self.thickness != 0.0 { + let t = self.thickness; + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; + } + Ok(Step::Done) + } + fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> { + let a = self.first?; + let loc = (p - a).rotate(-self.rotation); + Some(match self.dims { + Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }), + None => (loc.x, loc.y), + }) + } +} + +impl Interactive for RectM2 { + fn name(&self) -> &'static str { + "RECTANG" + } + fn prompt(&self, _s: &Session) -> Prompt { + let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}")); + if let Some(a) = self.asking { + return match a { + RAsk::Chamfer1 => num("Specify first chamfer distance for rectangles", self.chamfer.0), + RAsk::Chamfer2 => num("Specify second chamfer distance for rectangles", self.chamfer.1), + RAsk::Fillet => num("Specify fillet radius for rectangles", self.fillet), + RAsk::Width => num("Specify line width for rectangles", self.width), + RAsk::Elevation => num("Specify the elevation for rectangles", self.elevation), + RAsk::Thickness => num("Specify thickness for rectangles", self.thickness), + RAsk::Area => num("Enter area of rectangle in current units", if self.area > 0.0 { self.area } else { 100.0 }), + RAsk::AreaBasis => Prompt::new("Calculate rectangle dimensions based on", curves::KW).kw(&["Length", "Width"]).default("Length"), + RAsk::AreaLength => num("Enter rectangle length", 10.0), + RAsk::AreaWidth => num("Enter rectangle width", 10.0), + RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)), + RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)), + RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER) + .kw(&["Pick points"]) + .default(format!("{:.0}", self.rotation.to_degrees())) + .base_opt(self.first), + RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT), + RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b), + }; + } + 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 { + if let Some(a) = self.asking { + let val = |i: &Input, default: f64| -> Result { + match i { + Input::Text(t) => number(t).ok_or_else(|| other("Requires a number.")), + Input::Enter => Ok(default), + _ => Err(other("Requires a number.")), + } + }; + self.asking = None; + match a { + RAsk::Chamfer1 => { + self.chamfer.0 = val(&i, self.chamfer.0)?.max(0.0); + self.chamfer.1 = self.chamfer.0; + self.asking = Some(RAsk::Chamfer2); + } + RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0), + RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0), + RAsk::Width => self.width = val(&i, self.width)?.max(0.0), + RAsk::Elevation => self.elevation = val(&i, self.elevation)?, + RAsk::Thickness => self.thickness = val(&i, self.thickness)?, + RAsk::Area => { + let v = val(&i, if self.area > 0.0 { self.area } else { 100.0 })?; + if v <= 0.0 { + self.asking = Some(RAsk::Area); + return Err(other("Value must be positive.")); + } + self.area = v; + self.asking = Some(RAsk::AreaBasis); + } + RAsk::AreaBasis => { + let w = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('w')); + self.asking = Some(if w { RAsk::AreaWidth } else { RAsk::AreaLength }); + } + RAsk::AreaLength | RAsk::AreaWidth => { + let v = val(&i, 10.0)?; + if v <= 0.0 { + self.asking = Some(a); + return Err(other("Value must be positive.")); + } + let (w, h) = if a == RAsk::AreaLength { (v, self.area / v) } else { (self.area / v, v) }; + return self.make(s, w, h); + } + RAsk::DimL => { + let l = val(&i, self.dims.map(|d| d.0).unwrap_or(10.0))?.abs(); + self.dims = Some((l, self.dims.map(|d| d.1).unwrap_or(10.0))); + self.asking = Some(RAsk::DimW); + } + RAsk::DimW => { + let w = val(&i, self.dims.map(|d| d.1).unwrap_or(10.0))?.abs(); + self.dims = Some((self.dims.map(|d| d.0).unwrap_or(10.0), w)); + } + RAsk::Rotation => match i { + Input::Keyword(_) => self.asking = Some(RAsk::RotPick1), + Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0), + Input::Text(t) => self.rotation = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?, + _ => {} + }, + RAsk::RotPick1 => { + if let Input::Point(p) = i { + self.asking = Some(RAsk::RotPick2(p)); + } + } + RAsk::RotPick2(b) => { + if let Input::Point(p) = i { + self.rotation = b.angle_to(p); + } + } + } + return Ok(Step::Continue); + } + match i { + Input::Keyword(k) => { + self.asking = Some(match k.as_str() { + "Chamfer" => RAsk::Chamfer1, + "Elevation" => RAsk::Elevation, + "Fillet" => RAsk::Fillet, + "Thickness" => RAsk::Thickness, + "Width" => RAsk::Width, + "Area" => RAsk::Area, + "Dimensions" => RAsk::DimL, + _ => RAsk::Rotation, + }); + Ok(Step::Continue) + } + Input::Point(p) => match self.first { + None => { + self.first = Some(p); + Ok(Step::Continue) + } + Some(_) => { + let (w, h) = self.other_corner(p).ok_or_else(|| other("No first corner."))?; + self.make(s, w, h) + } + }, + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.asking.is_some() { + return Vec::new(); + } + match (self.first, self.other_corner(c)) { + (Some(a), Some((w, h))) if w.abs() > 1e-12 && h.abs() > 1e-12 => { + vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)] + } + _ => Vec::new(), + } + } +} + +// ===================================================================================== +// 3DPOLY +// ===================================================================================== + +fn point3_value(v: &Value) -> Option { + let a = v.as_array()?; + let x = a.first()?.as_f64()?; + let y = a.get(1)?.as_f64()?; + let z = a.get(2).and_then(Value::as_f64).unwrap_or(0.0); + let p = Vec3::new(x, y, z); + p.is_finite().then_some(p) +} + +fn run_3dpoly(s: &mut Session, p: &Value) -> Result { + let pts: Vec = p.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(point3_value).collect()).unwrap_or_default(); + if pts.len() < 2 || pts.len() > MAX_GEN { + return Err(bad("3dpoly", "`points` needs 2 or more [x,y,z] points")); + } + added(s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: bool_or(p, "closed", false) }))?) +} + +#[derive(Default)] +struct Poly3dM { + pts: Vec, + handle: Option, +} + +impl Poly3dM { + fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { + let k = EntityKind::Polyline3d(Polyline3d { points: self.pts.iter().map(|p| v3(*p)).collect(), closed }); + match (self.handle, self.pts.len() >= 2) { + (Some(h), true) => s.doc_mut()?.modify_entity(h, |e| e.kind = k)?, + (None, true) => self.handle = Some(s.add_entity(k)?), + (Some(h), false) => { + s.doc_mut()?.remove_entity(h); + self.handle = None; + } + (None, false) => {} + } + Ok(()) + } +} + +impl Interactive for Poly3dM { + fn name(&self) -> &'static str { + "3DPOLY" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.pts.len() { + 0 => Prompt::new("Specify start point of polyline", Accept::POINT), + 1 | 2 => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN { + self.pts.push(p); + self.sync(s, false)?; + } + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Undo" => { + self.pts.pop(); + self.sync(s, false)?; + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Close" => { + self.sync(s, true)?; + Ok(Step::Done) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default() + } +} + +// ===================================================================================== +// MLINE (two offset polylines) +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Just { + Top, + Zero, + Bottom, +} + +fn mline_offsets(j: Just, scale: f64) -> [f64; 2] { + match j { + Just::Top => [0.0, -scale], + Just::Zero => [scale / 2.0, -scale / 2.0], + Just::Bottom => [scale, 0.0], + } +} + +fn mline_kinds(pts: &[Vec2], closed: bool, j: Just, scale: f64) -> Vec { + if pts.len() < 2 { + return Vec::new(); + } + let pl = Polyline::from_points(pts, closed); + mline_offsets(j, scale) + .iter() + .filter_map(|d| { + let vs = if d.abs() < 1e-15 { Some(pl.vertices.clone()) } else { super::modify::offset_polyline(&pl, *d) }; + vs.map(|vs| lwpoly(vs, closed)) + }) + .collect() +} + +fn just_of(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "t" | "top" => Some(Just::Top), + "z" | "zero" => Some(Just::Zero), + "b" | "bottom" => Some(Just::Bottom), + _ => None, + } +} + +fn mline_defaults(s: &Session) -> (Just, f64) { + let d = s.doc().ok(); + let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); + let scale = d.map(|d| d.header.f64("CMLSCALE", if metric { 20.0 } else { 1.0 })).unwrap_or(1.0); + let j = match d.map(|d| d.header.i64("CMLJUST", 0)).unwrap_or(0) { + 1 => Just::Zero, + 2 => Just::Bottom, + _ => Just::Top, + }; + (j, scale) +} + +fn run_mline(s: &mut Session, p: &Value) -> Result { + let pts = points_param(p, "points").ok_or_else(|| bad("mline", "`points` (2 or more) is required"))?; + if pts.len() < 2 || pts.len() > MAX_GEN { + return Err(bad("mline", "need 2 or more points")); + } + let (dj, ds) = mline_defaults(s); + let j = match str_param(p, "justification") { + Some(t) => just_of(t).ok_or_else(|| bad("mline", "justification must be top, zero or bottom"))?, + None => dj, + }; + let scale = f64_or(p, "scale", ds); + let mut hs = Vec::new(); + for k in mline_kinds(&pts, bool_or(p, "closed", false), j, scale) { + hs.push(s.add_entity(k)?.hex()); + } + Ok(json!({ "handles": hs })) +} + +struct MlineM { + pts: Vec, + just: Just, + scale: f64, + asking: u8, // 0 none, 1 justification, 2 scale +} + +impl MlineM { + fn new(s: &Session) -> Self { + let (just, scale) = mline_defaults(s); + MlineM { pts: Vec::new(), just, scale, asking: 0 } + } + fn finish(&self, s: &mut Session, closed: bool) -> Result { + for k in mline_kinds(&self.pts, closed, self.just, self.scale) { + s.add_entity(k)?; + } + Ok(Step::Done) + } +} + +impl Interactive for MlineM { + fn name(&self) -> &'static str { + "MLINE" + } + fn begin(&mut self, s: &mut Session) -> Result { + let j = match self.just { + Just::Top => "Top", + Just::Zero => "Zero", + Just::Bottom => "Bottom", + }; + s.echo(format!("Current settings: Justification = {j}, Scale = {:.2}, Style = STANDARD", self.scale)); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.asking { + 1 => return Prompt::new("Enter justification type", curves::KW).kw(&["Top", "Zero", "Bottom"]).default("top"), + 2 => return Prompt::new("Enter mline scale", Accept::NUMBER).default(format!("{:.2}", self.scale)), + _ => {} + } + match self.pts.len() { + 0 => Prompt::new("Specify start point", Accept::POINT).kw(&["Justification", "Scale", "STyle"]), + 1 => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), + 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking == 1 { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.just = just_of(k).ok_or_else(|| other("Enter Top, Zero or Bottom."))?; + s.doc_mut()?.header.set_i64( + "CMLJUST", + match self.just { + Just::Top => 0, + Just::Zero => 1, + Just::Bottom => 2, + }, + ); + } + self.asking = 0; + return Ok(Step::Continue); + } + if self.asking == 2 { + if let Input::Text(t) = &i { + self.scale = number(t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a number."))?; + s.doc_mut()?.header.set_f64("CMLSCALE", self.scale); + } + self.asking = 0; + return Ok(Step::Continue); + } + match i { + Input::Keyword(k) => { + match k.as_str() { + "Justification" => self.asking = 1, + "Scale" => self.asking = 2, + "Undo" => { + self.pts.pop(); + } + "Close" => return self.finish(s, true), + _ => s.echo("Only the STANDARD multiline style is available."), + } + Ok(Step::Continue) + } + Input::Point(p) => { + if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN { + self.pts.push(p); + } + Ok(Step::Continue) + } + Input::Enter => { + if self.pts.len() >= 2 { + self.finish(s, false) + } else { + Ok(Step::Cancel) + } + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() || self.asking != 0 { + return Vec::new(); + } + let mut pts = self.pts.clone(); + pts.push(c); + mline_kinds(&pts, false, self.just, self.scale) + } +} + +// ===================================================================================== +// HELIX (3D polyline approximation) +// ===================================================================================== + +pub(crate) fn helix_points(c: Vec2, rb: f64, rt: f64, height: f64, turns: f64, ccw: bool, a0: f64) -> Vec { + let turns = turns.clamp(1e-3, 500.0); + let n = ((turns * 36.0).ceil() as usize).clamp(8, MAX_GEN); + let sign = if ccw { 1.0 } else { -1.0 }; + (0..=n) + .map(|i| { + let t = i as f64 / n as f64; + let a = a0 + sign * TAU * turns * t; + let r = rb + (rt - rb) * t; + let p = Vec2::polar(c, r, a); + Vec3::new(p.x, p.y, height * t) + }) + .collect() +} + +fn run_helix(s: &mut Session, p: &Value) -> Result { + let c = point_req("helix", p, "center")?; + let rb = f64_req("helix", p, "baseRadius")?; + let rt = f64_or(p, "topRadius", rb); + let turns = f64_or(p, "turns", 3.0); + if !(rb > 0.0 && rt >= 0.0 && turns > 0.0 && turns <= 500.0) { + return Err(bad("helix", "radii must be positive and turns in (0, 500]")); + } + let pts = helix_points(c, rb, rt, f64_or(p, "height", 1.0), turns, bool_or(p, "ccw", true), f64_or(p, "startAngle", 0.0).to_radians()); + added(s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: false }))?) +} + +#[derive(Default)] +struct HelixM { + center: Option, + rb: Option, + rt: Option, + turns: Option, + cw: bool, + diameter: bool, + asking: u8, // 0 main, 1 turns, 2 twist, 3 turn height + start: Option, +} + +impl HelixM { + fn turns(&self) -> f64 { + self.turns.unwrap_or(3.0) + } + fn create(&self, s: &mut Session, height: f64) -> Result { + let (Some(c), Some(rb)) = (self.center, self.rb) else { return Ok(Step::Cancel) }; + let rt = self.rt.unwrap_or(rb); + let pts = helix_points(c, rb, rt, height, self.turns(), !self.cw, self.start.unwrap_or(0.0)); + s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: false }))?; + Ok(Step::Done) + } +} + +impl Interactive for HelixM { + fn name(&self) -> &'static str { + "HELIX" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.echo(format!("Number of turns = {:.4} Twist=CCW", self.turns())); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.asking { + 1 => return Prompt::new("Enter number of turns", Accept::NUMBER).default(format!("{:.4}", self.turns())), + 2 => return Prompt::new("Enter twist direction of helix", curves::KW).kw(&["CW", "CCW"]).default("CCW"), + 3 => return Prompt::new("Specify distance between turns", Accept::NUMBER), + _ => {} + } + match (self.center, self.rb, self.rt) { + (None, ..) => Prompt::new("Specify center point of base", Accept::POINT), + (Some(c), None, _) => { + Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER) + .kw(if self.diameter { &[] } else { &["Diameter"] }) + .default("1.0000") + .base(c) + } + (Some(c), Some(rb), None) => { + Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER) + .kw(if self.diameter { &[] } else { &["Diameter"] }) + .default(format!("{rb:.4}")) + .base(c) + } + (Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER) + .kw(&["Axis endpoint", "Turns", "turn Height", "tWist"]) + .default("1.0000") + .base(c), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.asking { + 1 => { + if let Input::Text(t) = &i { + let v = number(t).filter(|v| *v > 0.0 && *v <= 500.0).ok_or_else(|| other("Turns must be in (0, 500]."))?; + self.turns = Some(v); + } + self.asking = 0; + return Ok(Step::Continue); + } + 2 => { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.cw = k.eq_ignore_ascii_case("cw"); + } + self.asking = 0; + return Ok(Step::Continue); + } + 3 => { + self.asking = 0; + if let Input::Text(t) = &i { + let v = number(t).ok_or_else(|| other("Requires a distance."))?; + return self.create(s, v * self.turns()); + } + return Ok(Step::Continue); + } + _ => {} + } + let num = |i: &Input, base: Option| -> Option { + match i { + Input::Point(p) => base.map(|b| b.dist(*p)), + Input::Text(t) => number(t), + _ => None, + } + }; + match (self.center, self.rb, self.rt, &i) { + (None, _, _, Input::Point(p)) => { + self.center = Some(*p); + Ok(Step::Continue) + } + (Some(_), None | Some(_), _, Input::Keyword(k)) if k == "Diameter" => { + self.diameter = true; + Ok(Step::Continue) + } + (Some(c), None, _, _) => { + let v = match &i { + Input::Enter => Some(1.0), + _ => num(&i, Some(c)), + }; + let v = v.filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive distance."))?; + if let Input::Point(p) = &i { + self.start = Some(c.angle_to(*p)); + } + self.rb = Some(if self.diameter { v / 2.0 } else { v }); + self.diameter = false; + Ok(Step::Continue) + } + (Some(c), Some(rb), None, _) => { + let v = match &i { + Input::Enter => Some(if self.diameter { rb * 2.0 } else { rb }), + _ => num(&i, Some(c)), + }; + let v = v.filter(|v| *v >= 0.0).ok_or_else(|| other("Requires a distance."))?; + self.rt = Some(if self.diameter { v / 2.0 } else { v }); + Ok(Step::Continue) + } + (Some(c), Some(_), Some(_), _) => match &i { + Input::Keyword(k) => { + self.asking = match k.as_str() { + "Turns" => 1, + "tWist" => 2, + "turn Height" => 3, + _ => 0, + }; + Ok(Step::Continue) + } + Input::Enter => self.create(s, 1.0), + _ => { + let h = num(&i, Some(c)).ok_or_else(|| other("Requires a distance."))?; + self.create(s, h) + } + }, + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match (self.center, self.rb, self.rt) { + (Some(ct), None, _) => vec![circle(ct, ct.dist(c))], + (Some(ct), Some(rb), None) => { + let pts = helix_points(ct, rb, ct.dist(c), 0.0, self.turns(), !self.cw, self.start.unwrap_or(0.0)); + vec![EntityKind::Polyline3d(Polyline3d { points: pts, closed: false })] + } + _ => Vec::new(), + } + } +} + +// ===================================================================================== +// SPLINE by control vertices +// ===================================================================================== + +fn run_spline_cv(s: &mut Session, p: &Value) -> Result { + let mut c = points_param(p, "control").ok_or_else(|| bad("spline.cv", "`control` points are required"))?; + if c.len() < 2 || c.len() > MAX_GEN { + return Err(bad("spline.cv", "need 2+ control points")); + } + let closed = bool_or(p, "closed", false); + if closed && let Some(f) = c.first().copied() { + c.push(f); + } + let deg = p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize; + let mut sp = Spline::from_control(c, deg); + sp.closed = closed; + added(s.add_entity(EntityKind::Spline(sp))?) +} + +#[derive(Default)] +struct SplineCvM { + pts: Vec, + degree: Option, + asking_degree: bool, +} + +impl Interactive for SplineCvM { + fn name(&self) -> &'static str { + "SPLINE" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.echo(format!("Current settings: Method=CV Degree={}", self.degree.unwrap_or(3))); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking_degree { + return Prompt::new("Enter degree of spline", Accept::NUMBER).default(self.degree.unwrap_or(3).to_string()); + } + match self.pts.len() { + 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Degree", "Object"]), + 1 => Prompt::new("Enter next point", Accept::POINT).base_opt(self.pts.last().copied()), + 2 => Prompt::new("Enter next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Enter next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking_degree { + if let Input::Text(t) = &i { + let d = number(t).filter(|d| (1.0..=10.0).contains(d)).ok_or_else(|| other("Degree must be 1..10."))?; + self.degree = Some(d as usize); + } + self.asking_degree = false; + return Ok(Step::Continue); + } + let deg = self.degree.unwrap_or(3); + match i { + Input::Point(p) => { + if self.pts.len() < MAX_GEN { + self.pts.push(p); + } + Ok(Step::Continue) + } + Input::Keyword(k) => { + match k.as_str() { + "Degree" => self.asking_degree = true, + "Undo" => { + self.pts.pop(); + } + "Close" => { + let mut c = self.pts.clone(); + c.extend(self.pts.first().copied()); + let mut sp = Spline::from_control(c, deg); + sp.closed = true; + s.add_entity(EntityKind::Spline(sp))?; + return Ok(Step::Done); + } + _ => s.echo(format!("{k}: use SPLINE for fit points.")), + } + Ok(Step::Continue) + } + Input::Enter => { + if self.pts.len() >= 2 { + s.add_entity(EntityKind::Spline(Spline::from_control(self.pts.clone(), deg)))?; + } + Ok(Step::Done) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() { + return Vec::new(); + } + let mut pts = self.pts.clone(); + pts.push(c); + vec![EntityKind::Spline(Spline::from_control(pts, self.degree.unwrap_or(3)))] + } +} + +// ===================================================================================== +// CENTERMARK / CENTERLINE +// ===================================================================================== + +/// Add a line on the CENTER linetype (loading it from the standard library when missing). +fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result { + let d = s.doc_mut()?; + if d.linetype("CENTER").is_none() + && let Some(lt) = cadcraft_doc::library::standard_linetypes().into_iter().find(|l| l.name == "CENTER") + { + d.linetypes.push(lt); + } + let h = s.add_entity(line(a, b))?; + s.doc_mut()?.modify_entity(h, |e| e.common.linetype = "CENTER".into())?; + Ok(h) +} + +fn center_ext(s: &Session) -> f64 { + let d = s.doc().ok(); + let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); + d.map(|d| d.header.f64("CENTEREXE", if metric { 3.5 } else { 0.12 })).unwrap_or(0.12) +} + +fn center_mark(s: &mut Session, h: Handle) -> Result> { + let e = curves::entity(s, h)?; + let (c, r) = match &e.kind { + EntityKind::Circle(c) => (c.center.xy(), c.radius), + EntityKind::Arc(a) => (a.center.xy(), a.radius), + _ => return Err(other("Select a circle or arc.")), + }; + let ext = r + center_ext(s); + let h1 = add_center_line(s, c - Vec2::X * ext, c + Vec2::X * ext)?; + let h2 = add_center_line(s, c - Vec2::Y * ext, c + Vec2::Y * ext)?; + Ok(vec![h1.hex(), h2.hex()]) +} + +fn center_line(s: &mut Session, h1: Handle, h2: Handle) -> Result { + let l = |h: Handle| -> Result<(Vec2, Vec2)> { + match curves::entity(s, h)?.kind { + EntityKind::Line(l) => Ok((l.a.xy(), l.b.xy())), + _ => Err(other("Select a line.")), + } + }; + let (a1, b1) = l(h1)?; + let (mut a2, mut b2) = l(h2)?; + if (b1 - a1).dot(b2 - a2) < 0.0 { + std::mem::swap(&mut a2, &mut b2); + } + let (st, en) = (a1.mid(a2), b1.mid(b2)); + let dir = (en - st).normalized(); + if dir == Vec2::ZERO { + return Err(other("The lines do not define a center line.")); + } + let ext = center_ext(s); + add_center_line(s, st - dir * ext, en + dir * ext) +} + +fn run_centermark(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + if hs.is_empty() { + return Err(bad("centermark", "`handle` or `handles` is required")); + } + let mut out = Vec::new(); + for h in hs { + out.extend(center_mark(s, h)?); + } + Ok(json!({ "handles": out })) +} + +fn run_centerline(s: &mut Session, p: &Value) -> Result { + let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("centerline", "`h1` is required"))?; + let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("centerline", "`h2` is required"))?; + added(center_line(s, h1, h2)?) +} + +struct CenterM { + first: Option, + mark: bool, +} + +impl Interactive for CenterM { + fn name(&self) -> &'static str { + if self.mark { "CENTERMARK" } else { "CENTERLINE" } + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.mark, self.first) { + (true, _) => Prompt::new("Select circle or arc to add center mark", Accept::POINT), + (false, None) => Prompt::new("Select first line", Accept::POINT), + (false, Some(_)) => Prompt::new("Select second line", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + if self.mark { + center_mark(s, h)?; + return Ok(Step::Continue); + } + match self.first { + None => { + if !matches!(curves::entity(s, h)?.kind, EntityKind::Line(_)) { + return Err(other("Select a line.")); + } + self.first = Some(h); + Ok(Step::Continue) + } + Some(h1) => { + center_line(s, h1, h)?; + Ok(Step::Done) + } + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} + +#[cfg(test)] +#[path = "draw2_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/draw2_tests.rs b/crates/engine/src/cmd/draw2_tests.rs new file mode 100644 index 00000000..75c6fd96 --- /dev/null +++ b/crates/engine/src/cmd/draw2_tests.rs @@ -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 = + [[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 = [[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 = + 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 { + 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 = + 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 = + 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:"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8ea07b8a..967d8703 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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, 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, pub write: fn(&Drawing, &str) -> std::result::Result, String>, + /// PLOT / EXPORTPDF (optional: builds without a plotter report "not available"). + pub plot: Option, } static IO: OnceLock = OnceLock::new(); diff --git a/crates/engine/src/cmd/gripcmds.rs b/crates/engine/src/cmd/gripcmds.rs new file mode 100644 index 00000000..74b91ee6 --- /dev/null +++ b/crates/engine/src/cmd/gripcmds.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + let r = super::modify::transform_entities(s, hs, m, copy)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_grip_stretch(s: &mut Session, p: &Value) -> Result { + stretch(s, "grip.stretch", p) +} + +fn run_grip_move(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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)) +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs new file mode 100644 index 00000000..49d5fb89 --- /dev/null +++ b/crates/engine/src/cmd/layout.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>(), + }) + }) + .collect(), + )) +} + +/// `-LAYOUT`-style dispatcher. +fn run_layout(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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> { + 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 { + 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::>() })) +} + +fn run_mview(s: &mut Session, p: &Value) -> Result { + 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 { + 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, +} + +impl MviewM { + fn new(count: usize, choose: bool) -> Self { + MviewM { count, choose, first: None } + } + fn create(&self, s: &mut Session, rect: Option) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let cmd = "viewport.set"; + let d = s.doc()?; + let hs: Vec = + 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 { + 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::>(), + "papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::>(), + })); + }; + 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 = 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 { + 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, 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}"); + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6b331209..467836f3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index c69c9694..feb087fd 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { 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 { } fn run_fillet(s: &mut Session, p: &Value) -> Result { + 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)); diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs new file mode 100644 index 00000000..b0a7f720 --- /dev/null +++ b/crates/engine/src/cmd/modify2.rs @@ -0,0 +1,2301 @@ +//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, CHSPACE, +//! FLATTEN, plus the fillet/trim/extend extensions used by modify.rs (arcs, polylines, +//! ellipses and splines). + +use cadcraft_doc::{Common, Entity, EntityKind, Handle, LwPolyline, MAX_BLOCK_DEPTH, Space}; +use cadcraft_geom::{ + Arc, Ellipse, Line, Mat3, PolyVertex, Polyline, Segment, Spline, TAU, Vec2, ccw_sweep, intersect_ext, line_circle, line_line_infinite, norm_angle, +}; +use serde_json::{Value, json}; + +use super::curves::{self, Chain, MAX_GEN, TanObj}; +use super::helpers::*; +use super::machines::{SelOutcome, SelectPhase, number}; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("pedit", "Polyline", run_pedit) + .menu(&["Modify", "Object", "Polyline"]) + .alias(&["pe"]) + .params( + "{handle | handles, option: close|open|join|width|fit|spline|decurve|reverse|ltypegen|vertex, width?, handles2? (join), fuzz?, \ + action?: move|insert|straighten|width, index?, to?, startWidth?, endWidth?, convert?: bool}", + ) + .interactive(|_| Ok(Box::new(PeditM::default()))), + CommandSpec::new("splinedit", "Spline", run_splinedit) + .menu(&["Modify", "Object", "Spline"]) + .alias(&["spe"]) + .params("{handle, option: close|open|reverse|refit|purge|polyline|move, fit?, index?, to?, precision?}") + .interactive(|_| Ok(Box::new(SplineditM::default()))), + CommandSpec::new("lengthen", "Lengthen", run_lengthen) + .menu(&["Modify", "Lengthen"]) + .alias(&["len"]) + .params("{handle, pick?: [x,y] (end), delta? | deltaAngle? (deg) | percent? | total? | totalAngle? (deg) | to?: [x,y]} ({handle} alone measures)") + .interactive(|_| Ok(Box::new(LengthenM::default()))), + CommandSpec::new("align", "Align", run_align) + .menu(&["Modify", "3D Operations", "Align"]) + .alias(&["al"]) + .params("{handles?, s1, d1, s2?, d2?, scale?: bool}") + .interactive(|_| Ok(Box::new(AlignM::default()))), + CommandSpec::new("blend", "Blend", run_blend) + .menu(&["Modify", "Blend"]) + .alias(&["blendcurves"]) + .params("{h1, p1, h2, p2, continuity?: tangent|smooth}") + .interactive(|_| Ok(Box::new(BlendM::default()))), + CommandSpec::new("reverse", "Reverse", run_reverse) + .params("{handles?} (lines, polylines, 3D polylines, splines)") + .interactive(|_| Ok(Box::new(SelOp::new(SelKind::Reverse)))), + CommandSpec::new("ncopy", "Copy Nested Objects", run_ncopy) + .menu(&["Modify", "Copy Nested Objects"]) + .params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}") + .interactive(|_| Ok(Box::new(NcopyM::default()))), + CommandSpec::new("chspace", "Change Space", run_chspace) + .menu(&["Modify", "Change Space"]) + .params("{handles?, to?: \"model\" | layout name, viewport?: handle}") + .interactive(|_| Ok(Box::new(SelOp::new(SelKind::Chspace)))), + CommandSpec::new("flatten", "Flatten Objects", run_flatten) + .menu(&["Modify", "Flatten Objects"]) + .params("{handles?} (z = 0, 3D polylines become polylines)") + .interactive(|_| Ok(Box::new(SelOp::new(SelKind::Flatten)))), + CommandSpec::new("arraypath", "Path Array", run_arraypath) + .menu(&["Modify", "Array", "Path Array"]) + .params("{handles?, path (handle), count? (default 6) | spacing?, align?: bool (default true)}") + .interactive(|_| Ok(Box::new(PathArrayM::default()))), + CommandSpec::new("textedit", "Edit Text...", run_textedit) + .menu(&["Modify", "Object", "Edit Text..."]) + .alias(&["ed", "ddedit"]) + .params("{handle, text} (text, mtext, attribute definitions, dimension text override)") + .interactive(|_| Ok(Box::new(TextEditM::default()))), + ] +} + +fn other(m: impl Into) -> EngineError { + EngineError::Other(m.into()) +} + +fn set_kind(s: &mut Session, h: Handle, k: EntityKind) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| e.kind = k)?; + Ok(()) +} + +fn push_entity(s: &mut Session, space: &Space, common: Common, kind: EntityKind) -> Result { + let d = s.doc_mut()?; + d.ensure_layer(&common.layer); + let h = d.new_handle(); + let st = d.space_mut(space).ok_or_else(|| other("no such space"))?; + st.push(Entity { handle: h, common, kind }); + Ok(h) +} + +// ===================================================================================== +// PEDIT +// ===================================================================================== + +/// The polyline form of a line, arc or polyline. +pub(crate) fn as_lwpoly(k: &EntityKind) -> Option { + match k { + EntityKind::LwPolyline(p) => Some(p.clone()), + EntityKind::Line(_) | EntityKind::Arc(_) => { + let c = Chain::of(k)?; + Some(LwPolyline { vertices: c.vertices(), closed: false, const_width: 0.0, elevation: 0.0, plinegen: false }) + } + _ => None, + } +} + +/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex. +pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool) -> Vec { + let pts: Vec = vs.iter().map(|v| v.p).collect(); + let n = pts.len(); + if n < 3 { + return vs.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect(); + } + let at = |i: usize| pts.get(i).copied().unwrap_or_default(); + let tangent = |i: usize| -> Vec2 { + let (prev, next) = if closed { + (at((i + n - 1) % n), at((i + 1) % n)) + } else if i == 0 { + (at(0), at(1)) + } else if i + 1 == n { + (at(n - 2), at(n - 1)) + } else { + (at(i - 1), at(i + 1)) + }; + let t = (next - prev).normalized(); + if t == Vec2::ZERO { Vec2::X } else { t } + }; + // Open ends: mirror the neighbouring tangent across the chord for a natural end. + let tan_at = |i: usize| -> Vec2 { + if closed || (i != 0 && i + 1 != n) { + return tangent(i); + } + let (a, b, tb) = if i == 0 { (at(0), at(1), tangent(1)) } else { (at(n - 1), at(n - 2), -tangent(n - 2)) }; + let c = (b - a).normalized(); + let m = c * (2.0 * tb.dot(c)) - tb; + let m = if i == 0 { m } else { -m }; + if m.len() < 1e-12 { c } else { m.normalized() } + }; + let count = if closed { n } else { n - 1 }; + let mut out = Vec::with_capacity(count * 2 + 1); + for i in 0..count { + let p0 = at(i); + let p1 = at((i + 1) % n); + let t0 = tan_at(i); + let t1 = tan_at((i + 1) % n); + let v = p1 - p0; + let tsum = t0 + t1; + let denom = 2.0 * (1.0 - t0.dot(t1)); + let d = if denom.abs() < 1e-12 { + let vt = v.dot(t1); + if vt.abs() < 1e-12 { v.len() / 2.0 } else { v.len2() / (4.0 * vt) } + } else { + let vt = v.dot(tsum); + (-vt + (vt * vt + denom * v.len2()).max(0.0).sqrt()) / denom + }; + let pm = (p0 + t0 * d + p1 - t1 * d) / 2.0; + let b1 = curves::bulge_from_tangent(p0, pm, t0); + let b2 = -curves::bulge_from_tangent(p1, pm, -t1); + let w = vs.get(i).copied().unwrap_or_default(); + out.push(PolyVertex { p: p0, bulge: if b1.is_finite() { b1 } else { 0.0 }, start_width: w.start_width, end_width: w.end_width }); + out.push(PolyVertex { p: pm, bulge: if b2.is_finite() { b2 } else { 0.0 }, start_width: w.end_width, end_width: w.end_width }); + } + if !closed { + out.push(PolyVertex::new(at(n - 1))); + } + out +} + +/// Spline curve: a cubic B-spline with the vertices as its frame (control points). +pub(crate) fn spline_vertices(vs: &[PolyVertex], closed: bool, segs: usize) -> Vec { + let pts: Vec = vs.iter().map(|v| v.p).collect(); + let n = pts.len(); + if n < 3 { + return vs.to_vec(); + } + let segs = segs.clamp(1, 64).min((MAX_GEN / n).max(1)); + let mut out: Vec = Vec::new(); + if closed { + // Uniform periodic cubic B-spline. + let at = |i: usize| pts.get(i % n).copied().unwrap_or_default(); + for i in 0..n { + let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2)); + for k in 0..segs { + let t = k as f64 / segs as f64; + let (t2, t3) = (t * t, t * t * t); + let b0 = (1.0 - t).powi(3) / 6.0; + let b1 = (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0; + let b2 = (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0; + let b3 = t3 / 6.0; + out.push(p0 * b0 + p1 * b1 + p2 * b2 + p3 * b3); + } + } + } else { + let sp = Spline::from_control(pts.clone(), 3); + let (lo, hi) = sp.domain(); + let m = (segs * (n - 1)).min(MAX_GEN); + for k in 0..=m { + out.push(sp.eval(lo + (hi - lo) * k as f64 / m as f64)); + } + } + out.into_iter().map(PolyVertex::new).collect() +} + +/// Join connected lines/arcs/open polylines onto the ends of polyline `h` (kept in place). +pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f64) -> Result { + let e = curves::entity(s, h)?; + let mut base = as_lwpoly(&e.kind).ok_or_else(|| other("Select a polyline."))?; + if base.closed { + return Err(other("Cannot join to a closed polyline.")); + } + let mut chain = Chain::of(&EntityKind::LwPolyline(base.clone())).unwrap_or_default(); + let tol = fuzz.max(1e-6); + let mut pool: Vec<(Handle, Chain)> = Vec::new(); + for o in others { + if *o == h { + continue; + } + let Ok(oe) = curves::entity(s, *o) else { continue }; + let ok = matches!(&oe.kind, EntityKind::Line(_) | EntityKind::Arc(_)) || matches!(&oe.kind, EntityKind::LwPolyline(p) if !p.closed); + if ok && let Some(c) = Chain::of(&oe.kind) { + pool.push((*o, c)); + } + } + let mut joined = Vec::new(); + while let (Some(st), Some(en)) = (chain.start(), chain.end()) { + let Some(i) = pool + .iter() + .position(|(_, c)| c.start().zip(c.end()).is_some_and(|(a, b)| a.near(en, tol) || b.near(en, tol) || a.near(st, tol) || b.near(st, tol))) + else { + break; + }; + let (oh, c) = pool.remove(i); + let (Some(a), Some(b)) = (c.start(), c.end()) else { continue }; + if a.near(en, tol) { + chain.segs.extend(c.segs); + } else if b.near(en, tol) { + chain.segs.extend(c.reversed().segs); + } else if b.near(st, tol) { + let mut v = c.segs; + v.extend(chain.segs); + chain.segs = v; + } else { + let mut v = c.reversed().segs; + v.extend(chain.segs); + chain.segs = v; + } + joined.push(oh); + } + if joined.is_empty() { + return Ok(0); + } + chain.closed = chain.start().zip(chain.end()).is_some_and(|(a, b)| a.near(b, tol)) && chain.segs.len() > 1; + base.vertices = chain.vertices(); + base.closed = chain.closed; + let d = s.doc_mut()?; + d.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(base))?; + for o in &joined { + d.remove_entity(*o); + } + Ok(joined.len()) +} + +fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { + let e = curves::entity(s, h)?; + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let mut pl = match &e.kind { + EntityKind::LwPolyline(pl) => pl.clone(), + EntityKind::Line(_) | EntityKind::Arc(_) if bool_or(p, "convert", true) => as_lwpoly(&e.kind).ok_or_else(|| other("Cannot convert."))?, + _ => return Err(other("Object selected is not a polyline.")), + }; + match opt { + "close" | "c" => pl.closed = pl.vertices.len() > 1, + "open" | "o" => pl.closed = false, + "width" | "w" => { + let w = f64_req("pedit", p, "width")?; + if w < 0.0 { + return Err(bad("pedit", "width must be non-negative")); + } + pl.const_width = w; + for v in &mut pl.vertices { + v.start_width = 0.0; + v.end_width = 0.0; + } + } + "fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed), + "spline" | "s" => { + let segs = s.doc()?.header.i64("SPLINESEGS", 8).clamp(1, 64) as usize; + pl.vertices = spline_vertices(&pl.vertices, pl.closed, segs); + } + "decurve" | "d" => pl.vertices.iter_mut().for_each(|v| v.bulge = 0.0), + "reverse" | "r" => pl.vertices = curves::reverse_vertices(&pl.vertices, pl.closed), + "ltypegen" | "l" => pl.plinegen = bool_or(p, "on", !pl.plinegen), + "join" | "j" => { + set_kind(s, h, EntityKind::LwPolyline(pl))?; + let others: Vec = p + .get("handles2") + .or_else(|| p.get("join")) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(curves::handle_value).collect()) + .unwrap_or_default(); + let n = pedit_join(s, h, &others, f64_or(p, "fuzz", 0.0))?; + return Ok(json!({ "handle": h.hex(), "joined": n })); + } + "vertex" | "e" => { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("pedit", "`index` is required"))? as usize; + let n = pl.vertices.len(); + if i >= n { + return Err(bad("pedit", "vertex index out of range")); + } + match str_param(p, "action").unwrap_or("move") { + "move" => { + let to = point_req("pedit", p, "to")?; + if let Some(v) = pl.vertices.get_mut(i) { + v.p = to; + } + } + "insert" => { + let to = point_req("pedit", p, "to")?; + if n >= MAX_GEN { + return Err(bad("pedit", "too many vertices")); + } + if let Some(v) = pl.vertices.get_mut(i) { + v.bulge = 0.0; + } + pl.vertices.insert(i + 1, PolyVertex::new(to)); + } + "straighten" => { + if let Some(v) = pl.vertices.get_mut(i) { + v.bulge = 0.0; + } + } + "width" => { + let sw = f64_or(p, "startWidth", 0.0).max(0.0); + let ew = f64_or(p, "endWidth", sw).max(0.0); + if let Some(v) = pl.vertices.get_mut(i) { + v.start_width = sw; + v.end_width = ew; + } + } + _ => return Err(bad("pedit", "action must be move, insert, straighten or width")), + } + } + _ => return Err(bad("pedit", "unknown option")), + } + set_kind(s, h, EntityKind::LwPolyline(pl))?; + Ok(json!({ "handle": h.hex() })) +} + +fn run_pedit(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + if hs.is_empty() { + return Err(bad("pedit", "`handle` is required")); + } + let opt = str_param(p, "option").ok_or_else(|| bad("pedit", "`option` is required"))?.to_ascii_lowercase(); + let mut out = Vec::new(); + for h in hs { + out.push(pedit_apply(s, h, &opt, p)?); + } + Ok(if out.len() == 1 { out.pop().unwrap_or(Value::Null) } else { json!({ "results": out }) }) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum PPhase { + #[default] + Select, + Convert, + Main, + Width, + Join, + Vertex, + VMove, + VInsert, + VWidthStart, + VWidthEnd, +} + +#[derive(Default)] +struct PeditM { + phase: PPhase, + h: Option, + history: Vec, + original: Option>, + join: SelectPhase, + vertex: usize, + vwidth: f64, +} + +impl PeditM { + fn poly(&self, s: &Session) -> Option { + let e = s.doc().ok()?.entity(self.h?)?.clone(); + match &e.kind { + EntityKind::LwPolyline(p) => Some(p.clone()), + _ => None, + } + } + fn snapshot(&mut self, s: &Session) { + if let Some(h) = self.h + && let Ok(e) = curves::entity(s, h) + { + self.history.push(e.kind); + } + } + fn apply(&mut self, s: &mut Session, opt: &str, p: Value) -> Result<()> { + let h = self.h.ok_or_else(|| other("No polyline."))?; + self.snapshot(s); + if let Err(e) = pedit_apply(s, h, opt, &p) { + self.history.pop(); + return Err(e); + } + Ok(()) + } +} + +impl Interactive for PeditM { + fn name(&self) -> &'static str { + "PEDIT" + } + fn begin(&mut self, s: &mut Session) -> Result { + let sel = s.selection(); + if let [h] = sel.as_slice() { + let k = curves::entity(s, *h)?.kind; + if matches!(k, EntityKind::LwPolyline(_)) { + self.h = Some(*h); + self.original = self.poly(s).map(|p| p.vertices); + self.phase = PPhase::Main; + } + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match self.phase { + PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), + PPhase::Convert => { + Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") + } + PPhase::Main => { + let closed = self.poly(s).is_some_and(|p| p.closed); + let first = if closed { "Open" } else { "Close" }; + Prompt::new("Enter an option", curves::KW).kw(&[ + first, + "Join", + "Width", + "Edit vertex", + "Fit", + "Spline", + "Decurve", + "Ltype gen", + "Reverse", + "Undo", + ]) + } + PPhase::Width => Prompt::new("Specify new width for all segments", Accept::NUMBER), + PPhase::Join => self.join.prompt(), + PPhase::Vertex => Prompt::new("Enter a vertex editing option", curves::KW) + .kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]) + .default("N"), + PPhase::VMove => Prompt::new("Specify new location for marked vertex", Accept::POINT), + PPhase::VInsert => Prompt::new("Specify location for new vertex", Accept::POINT), + PPhase::VWidthStart => Prompt::new("Specify starting width for next segment", Accept::NUMBER), + PPhase::VWidthEnd => Prompt::new("Specify ending width for next segment", Accept::NUMBER).default(format!("{:.4}", self.vwidth)), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let kw = |i: &Input| match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), + _ => None, + }; + match self.phase { + PPhase::Select => match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let k = curves::entity(s, h)?.kind; + self.h = Some(h); + match k { + EntityKind::LwPolyline(pl) => { + self.original = Some(pl.vertices); + self.phase = PPhase::Main; + } + EntityKind::Line(_) | EntityKind::Arc(_) => self.phase = PPhase::Convert, + _ => { + self.h = None; + return Err(other("Object selected is not a polyline.")); + } + } + s.set_selection(vec![h]); + Ok(Step::Continue) + } + Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")), + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }, + PPhase::Convert => { + let no = kw(&i).is_some_and(|k| k.starts_with('n')); + if no { + return Ok(Step::Done); + } + let h = self.h.ok_or_else(|| other("No object."))?; + let k = curves::entity(s, h)?.kind; + let pl = as_lwpoly(&k).ok_or_else(|| other("Cannot convert."))?; + self.original = Some(pl.vertices.clone()); + set_kind(s, h, EntityKind::LwPolyline(pl))?; + self.phase = PPhase::Main; + Ok(Step::Continue) + } + PPhase::Main => { + let Some(k) = kw(&i) else { + if i == Input::Enter { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + return Ok(Step::Continue); + }; + match k.as_str() { + "open" | "o" => self.apply(s, "open", json!({}))?, + "close" | "c" => self.apply(s, "close", json!({}))?, + "join" | "j" => { + self.join = SelectPhase::default(); + self.phase = PPhase::Join; + } + "width" | "w" => self.phase = PPhase::Width, + "edit vertex" | "e" => { + self.vertex = 0; + self.phase = PPhase::Vertex; + } + "fit" | "f" => self.apply(s, "fit", json!({}))?, + "spline" | "s" => self.apply(s, "spline", json!({}))?, + "decurve" | "d" => { + // Within the session, Decurve restores the frame before Fit/Spline. + let h = self.h.ok_or_else(|| other("No polyline."))?; + self.snapshot(s); + match (self.poly(s), self.original.clone()) { + (Some(mut pl), Some(orig)) => { + pl.vertices = orig.into_iter().map(|v| PolyVertex { bulge: 0.0, ..v }).collect(); + set_kind(s, h, EntityKind::LwPolyline(pl))?; + } + _ => { + self.history.pop(); + self.apply(s, "decurve", json!({}))?; + } + } + } + "ltype gen" | "l" => self.apply(s, "ltypegen", json!({}))?, + "reverse" | "r" => self.apply(s, "reverse", json!({}))?, + "undo" | "u" => { + if let (Some(h), Some(k)) = (self.h, self.history.pop()) { + set_kind(s, h, k)?; + } + } + _ => return Err(other("Invalid option keyword.")), + } + Ok(Step::Continue) + } + PPhase::Width => { + if let Input::Text(t) = &i { + let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; + self.apply(s, "width", json!({ "width": w }))?; + } + self.phase = PPhase::Main; + Ok(Step::Continue) + } + PPhase::Join => match self.join.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => { + self.phase = PPhase::Main; + Ok(Step::Continue) + } + SelOutcome::Done(hs) => { + let h = self.h.ok_or_else(|| other("No polyline."))?; + self.snapshot(s); + let n = pedit_join(s, h, &hs, 0.0)?; + s.echo(format!("{n} segments added to polyline")); + s.set_selection(vec![h]); + self.phase = PPhase::Main; + Ok(Step::Continue) + } + }, + PPhase::Vertex => { + let n = self.poly(s).map(|p| p.vertices.len()).unwrap_or(0).max(1); + let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() }); + match k.as_str() { + "next" | "n" => self.vertex = (self.vertex + 1) % n, + "previous" | "p" => self.vertex = (self.vertex + n - 1) % n, + "move" | "m" => self.phase = PPhase::VMove, + "insert" | "i" => self.phase = PPhase::VInsert, + "straighten" | "s" => { + self.apply(s, "vertex", json!({ "action": "straighten", "index": self.vertex }))?; + } + "width" | "w" => self.phase = PPhase::VWidthStart, + "exit" | "x" => self.phase = PPhase::Main, + "" => {} + other_k => s.echo(format!("{other_k}: not available yet")), + } + Ok(Step::Continue) + } + PPhase::VMove | PPhase::VInsert => { + if let Input::Point(p) = i { + let action = if self.phase == PPhase::VMove { "move" } else { "insert" }; + self.apply(s, "vertex", json!({ "action": action, "index": self.vertex, "to": [p.x, p.y] }))?; + if action == "insert" { + self.vertex += 1; + } + } + self.phase = PPhase::Vertex; + Ok(Step::Continue) + } + PPhase::VWidthStart => { + if let Input::Text(t) = &i { + self.vwidth = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; + self.phase = PPhase::VWidthEnd; + } else { + self.phase = PPhase::Vertex; + } + Ok(Step::Continue) + } + PPhase::VWidthEnd => { + let ew = match &i { + Input::Text(t) => number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?, + _ => self.vwidth, + }; + self.apply(s, "vertex", json!({ "action": "width", "index": self.vertex, "startWidth": self.vwidth, "endWidth": ew }))?; + self.phase = PPhase::Vertex; + Ok(Step::Continue) + } + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if !matches!(self.phase, PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd) { + return Vec::new(); + } + let Some(v) = self.poly(s).and_then(|p| p.vertices.get(self.vertex).copied()) else { return Vec::new() }; + let k = s.pixel_size() * 8.0; + let mut out = vec![line(v.p - Vec2::new(k, k), v.p + Vec2::new(k, k)), line(v.p - Vec2::new(k, -k), v.p + Vec2::new(k, -k))]; + if matches!(self.phase, PPhase::VMove | PPhase::VInsert) { + out.push(line(v.p, c)); + } + out + } +} + +// ===================================================================================== +// SPLINEDIT +// ===================================================================================== + +fn spline_close(sp: &Spline) -> Spline { + if sp.closed { + return sp.clone(); + } + let mut out = if sp.fit.len() >= 2 { + let mut f = sp.fit.clone(); + f.extend(sp.fit.first().copied()); + Spline::from_fit_points(&f) + } else { + let mut c = sp.control.clone(); + c.extend(sp.control.first().copied()); + Spline::from_control(c, sp.degree) + }; + out.closed = true; + out +} + +fn spline_open(sp: &Spline) -> Spline { + if !sp.closed { + return sp.clone(); + } + let trim = |v: &[Vec2]| -> Vec { + let mut v = v.to_vec(); + if v.len() > 2 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) { + v.pop(); + } + v + }; + if sp.fit.len() >= 2 { Spline::from_fit_points(&trim(&sp.fit)) } else { Spline::from_control(trim(&sp.control), sp.degree) } +} + +pub(crate) fn spline_to_poly(sp: &Spline, precision: usize) -> LwPolyline { + let (lo, hi) = sp.domain(); + let n = (precision.clamp(1, 99) * sp.control.len().max(2)).min(MAX_GEN); + let vs = (0..=n).map(|i| PolyVertex::new(sp.eval(lo + (hi - lo) * i as f64 / n as f64))).collect::>(); + let mut vs = vs; + if sp.closed && vs.len() > 2 { + vs.pop(); + } + LwPolyline { vertices: vs, closed: sp.closed, const_width: 0.0, elevation: 0.0, plinegen: false } +} + +fn run_splinedit(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("splinedit", "`handle` is required"))?; + let e = curves::entity(s, h)?; + let EntityKind::Spline(sp) = &e.kind else { return Err(other("Object selected is not a spline.")) }; + let opt = str_param(p, "option").ok_or_else(|| bad("splinedit", "`option` is required"))?.to_ascii_lowercase(); + let k = match opt.as_str() { + "close" => EntityKind::Spline(spline_close(sp)), + "open" => EntityKind::Spline(spline_open(sp)), + "reverse" => EntityKind::Spline(curves::reverse_spline(sp)), + "purge" => EntityKind::Spline(Spline { fit: Vec::new(), ..sp.clone() }), + "refit" => { + let fit = points_param(p, "fit").unwrap_or_else(|| sp.fit.clone()); + if fit.len() < 2 || fit.len() > MAX_GEN { + return Err(other("The spline has no fit data; give `fit` points.")); + } + let mut n = Spline::from_fit_points(&fit); + n.closed = sp.closed; + EntityKind::Spline(n) + } + "polyline" => EntityKind::LwPolyline(spline_to_poly(sp, p.get("precision").and_then(Value::as_u64).unwrap_or(10) as usize)), + "move" => { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("splinedit", "`index` is required"))? as usize; + let to = point_req("splinedit", p, "to")?; + let mut n = sp.clone(); + if !n.fit.is_empty() { + let f = n.fit.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?; + *f = to; + let closed = n.closed; + n = Spline::from_fit_points(&n.fit); + n.closed = closed; + } else { + let c = n.control.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?; + *c = to; + } + EntityKind::Spline(n) + } + _ => return Err(bad("splinedit", "unknown option")), + }; + set_kind(s, h, k)?; + Ok(json!({ "handle": h.hex() })) +} + +#[derive(Default)] +struct SplineditM { + h: Option, + fit_menu: bool, + history: Vec, +} + +impl Interactive for SplineditM { + fn name(&self) -> &'static str { + "SPLINEDIT" + } + fn prompt(&self, s: &Session) -> Prompt { + let Some(h) = self.h else { return Prompt::new("Select spline", Accept::POINT) }; + if self.fit_menu { + return Prompt::new("Enter a fit data option", curves::KW) + .kw(&["Add", "Close", "Delete", "Kink", "Move", "Purge", "Tangents", "toLerance", "eXit"]) + .default("eXit"); + } + let closed = s.doc().ok().and_then(|d| d.entity(h)).is_some_and(|e| matches!(&e.kind, EntityKind::Spline(sp) if sp.closed)); + Prompt::new("Enter an option", curves::KW) + .kw(&[if closed { "Open" } else { "Close" }, "Join", "Fit data", "Edit vertex", "convert to Polyline", "Reverse", "Undo", "eXit"]) + .default("eXit") + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some(h) = self.h else { + return match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + if !matches!(curves::entity(s, h)?.kind, EntityKind::Spline(_)) { + return Err(other("Object selected is not a spline.")); + } + self.h = Some(h); + s.set_selection(vec![h]); + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }; + }; + let k = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => "exit".into(), + _ => return Ok(Step::Continue), + }; + let mut apply = |s: &mut Session, opt: &str| -> Result<()> { + let before = curves::entity(s, h)?.kind; + run_splinedit(s, &json!({ "handle": h.hex(), "option": opt }))?; + self.history.push(before); + Ok(()) + }; + if self.fit_menu { + match k.as_str() { + "purge" | "p" => apply(s, "purge")?, + "close" | "c" => apply(s, "close")?, + "exit" | "x" => {} + _ => s.echo(format!("{k}: not available yet")), + } + self.fit_menu = false; + return Ok(Step::Continue); + } + match k.as_str() { + "close" | "c" => apply(s, "close")?, + "open" | "o" => apply(s, "open")?, + "reverse" | "r" => apply(s, "reverse")?, + "convert to polyline" | "p" => { + apply(s, "polyline")?; + return Ok(Step::Done); + } + "fit data" | "f" => self.fit_menu = true, + "undo" | "u" => { + if let Some(k) = self.history.pop() { + set_kind(s, h, k)?; + } + } + "exit" | "x" => { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + _ => s.echo(format!("{k}: not available yet")), + } + Ok(Step::Continue) + } +} + +// ===================================================================================== +// LENGTHEN +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum LenMode { + Delta(f64), + DeltaAngle(f64), + Percent(f64), + Total(f64), + TotalAngle(f64), + To(Vec2), +} + +/// Current length (and included angle for arcs) of a curve. +fn measure_len(k: &EntityKind) -> Option<(f64, Option)> { + match k { + EntityKind::Arc(a) => { + let g = Arc::new(a.center.xy(), a.radius, a.start, a.end); + Some((g.len(), Some(g.sweep()))) + } + _ => Chain::of(k).map(|c| (c.len(), None)), + } +} + +/// Lengthen a line, arc or open polyline at the end nearest `pick`. +pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option, mode: LenMode) -> Result { + let too_short = || other("Lengthening would make the object zero length or negative."); + match k { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + let at_b = pick.is_none_or(|p| p.dist(b) <= p.dist(a)); + let (fixed, end) = if at_b { (a, b) } else { (b, a) }; + let len = fixed.dist(end); + let dir = (end - fixed).normalized(); + if dir == Vec2::ZERO { + return Err(other("Zero-length line.")); + } + let nl = match mode { + LenMode::Delta(d) => len + d, + LenMode::Percent(p) => len * p / 100.0, + LenMode::Total(t) => t, + LenMode::To(q) => (q - fixed).dot(dir), + LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")), + }; + if !(nl > 1e-12 && nl.is_finite()) { + return Err(too_short()); + } + let ne = fixed + dir * nl; + Ok(if at_b { line(a, ne) } else { line(ne, b) }) + } + EntityKind::Arc(ar) => { + let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end); + let at_end = pick.is_none_or(|p| p.dist(g.end_point()) <= p.dist(g.start_point())); + let sw = g.sweep(); + let r = g.radius.max(1e-300); + let ns = match mode { + LenMode::Delta(d) => sw + d / r, + LenMode::DeltaAngle(a) => sw + a, + LenMode::Percent(p) => sw * p / 100.0, + LenMode::Total(t) => t / r, + LenMode::TotalAngle(a) => a, + LenMode::To(q) => { + let a = g.center.angle_to(q); + if at_end { ccw_sweep(g.start, a) } else { ccw_sweep(a, g.end) } + } + }; + if !(ns > 1e-12 && ns < TAU - 1e-9) { + return Err(other("The new arc would be zero length or a full circle.")); + } + Ok(if at_end { arc(&Arc::new(g.center, g.radius, g.start, g.start + ns)) } else { arc(&Arc::new(g.center, g.radius, g.end - ns, g.end)) }) + } + EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { + let total = Polyline { vertices: p.vertices.clone(), closed: false }.len(); + let mut vs = p.vertices.clone(); + let n = vs.len(); + let (first, last) = (vs.first().map(|v| v.p).unwrap_or_default(), vs.last().map(|v| v.p).unwrap_or_default()); + let at_end = pick.is_none_or(|q| q.dist(last) <= q.dist(first)); + if !at_end { + vs = curves::reverse_vertices(&vs, false); + } + let seg_start = vs.get(n - 2).copied().unwrap_or_default(); + if seg_start.bulge.abs() > 1e-12 { + return Err(other("The end segment is an arc; explode the polyline or use an open line end.")); + } + let end = vs.get(n - 1).map(|v| v.p).unwrap_or_default(); + let seg_len = seg_start.p.dist(end); + let dir = (end - seg_start.p).normalized(); + if dir == Vec2::ZERO { + return Err(other("Zero-length end segment.")); + } + let delta = match mode { + LenMode::Delta(d) => d, + LenMode::Percent(pc) => total * pc / 100.0 - total, + LenMode::Total(t) => t - total, + LenMode::To(q) => (q - seg_start.p).dot(dir) - seg_len, + LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")), + }; + let nl = seg_len + delta; + if !(nl > 1e-12 && nl.is_finite()) { + return Err(too_short()); + } + if let Some(v) = vs.get_mut(n - 1) { + v.p = seg_start.p + dir * nl; + } + if !at_end { + vs = curves::reverse_vertices(&vs, false); + } + Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() })) + } + _ => Err(other("Select a line, arc or open polyline.")), + } +} + +fn len_mode(p: &Value) -> Option { + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(v) = f("delta") { + Some(LenMode::Delta(v)) + } else if let Some(v) = f("deltaAngle") { + Some(LenMode::DeltaAngle(v.to_radians())) + } else if let Some(v) = f("percent") { + Some(LenMode::Percent(v)) + } else if let Some(v) = f("total") { + Some(LenMode::Total(v)) + } else if let Some(v) = f("totalAngle") { + Some(LenMode::TotalAngle(v.to_radians())) + } else { + point_param(p, "to").map(LenMode::To) + } +} + +fn run_lengthen(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("lengthen", "`handle` is required"))?; + let e = curves::entity(s, h)?; + let Some(mode) = len_mode(p) else { + let (l, a) = measure_len(&e.kind).ok_or_else(|| other("Cannot measure that object."))?; + return Ok(json!({ "length": l, "angle": a.map(f64::to_degrees) })); + }; + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let k = lengthen_kind(&e.kind, point_param(p, "pick"), mode)?; + let l = measure_len(&k).map(|x| x.0).unwrap_or(0.0); + set_kind(s, h, k)?; + Ok(json!({ "handle": h.hex(), "length": l })) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum LPhase { + #[default] + Main, + Delta, + DeltaAngle, + Percent, + Total, + TotalAngle, + Change, + DynEnd(Handle, Vec2), +} + +#[derive(Default)] +struct LengthenM { + phase: LPhase, + mode: Option, + dynamic: bool, + history: Vec<(Handle, EntityKind)>, +} + +impl Interactive for LengthenM { + fn name(&self) -> &'static str { + "LENGTHEN" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.phase { + LPhase::Main => { + Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") + } + LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), + LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"), + LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"), + LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), + LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"), + LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]), + LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let num = |i: &Input| -> Option { if let Input::Text(t) = i { number(t) } else { None } }; + let ang = |i: &Input| -> Option { if let Input::Text(t) = i { crate::units::parse_angle(t) } else { None } }; + match (self.phase, &i) { + (LPhase::Main, Input::Point(p)) => { + let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; + let k = curves::entity(s, h)?.kind; + match measure_len(&k) { + Some((l, Some(a))) => s.echo(format!("Current length: {l:.4}, included angle: {:.0}", a.to_degrees())), + Some((l, None)) => s.echo(format!("Current length: {l:.4}")), + None => s.echo("Cannot measure that object."), + } + Ok(Step::Continue) + } + (LPhase::Main, Input::Keyword(k)) => { + self.phase = match k.as_str() { + "DElta" => LPhase::Delta, + "Percent" => LPhase::Percent, + "Total" => LPhase::Total, + _ => { + self.dynamic = true; + LPhase::Change + } + }; + Ok(Step::Continue) + } + (LPhase::Main, Input::Enter) => { + self.dynamic = true; + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Delta | LPhase::Total, Input::Keyword(_)) => { + self.phase = if self.phase == LPhase::Delta { LPhase::DeltaAngle } else { LPhase::TotalAngle }; + Ok(Step::Continue) + } + (LPhase::Delta, _) => { + self.mode = Some(LenMode::Delta(num(&i).ok_or_else(|| other("Requires a distance."))?)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::DeltaAngle, _) => { + self.mode = Some(LenMode::DeltaAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Percent, _) => { + let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive percentage."))?; + self.mode = Some(LenMode::Percent(v)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Total, _) => { + let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive length."))?; + self.mode = Some(LenMode::Total(v)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::TotalAngle, _) => { + self.mode = Some(LenMode::TotalAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Change, Input::Point(p)) => { + let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; + if self.dynamic { + self.phase = LPhase::DynEnd(h, *p); + return Ok(Step::Continue); + } + let mode = self.mode.ok_or_else(|| other("No lengthen mode."))?; + let k = curves::entity(s, h)?.kind; + let nk = lengthen_kind(&k, Some(*p), mode)?; + set_kind(s, h, nk)?; + self.history.push((h, k)); + Ok(Step::Continue) + } + (LPhase::DynEnd(h, pick), Input::Point(p)) => { + let k = curves::entity(s, h)?.kind; + let nk = lengthen_kind(&k, Some(pick), LenMode::To(*p))?; + set_kind(s, h, nk)?; + self.history.push((h, k)); + self.phase = LPhase::Change; + Ok(Step::Continue) + } + (LPhase::Change, Input::Keyword(_)) => { + if let Some((h, k)) = self.history.pop() { + set_kind(s, h, k)?; + } + Ok(Step::Continue) + } + (_, Input::Enter) => Ok(Step::Done), + _ => Err(other("Invalid input.")), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let LPhase::DynEnd(h, pick) = self.phase + && let Ok(e) = curves::entity(s, h) + { + return lengthen_kind(&e.kind, Some(pick), LenMode::To(c)).map(|k| vec![k]).unwrap_or_default(); + } + Vec::new() + } +} + +// ===================================================================================== +// ALIGN +// ===================================================================================== + +pub(crate) fn align_matrix(s1: Vec2, d1: Vec2, pair2: Option<(Vec2, Vec2)>, scale: bool) -> Mat3 { + let mut m = Mat3::translate(d1 - s1); + if let Some((s2, d2)) = pair2 { + let vs = s2 - s1; + let vd = d2 - d1; + if vs.len() > 1e-12 && vd.len() > 1e-12 { + m = Mat3::rotate_about(d1, vd.angle() - vs.angle()).then_before(m); + if scale { + m = Mat3::scale_about(d1, vd.len() / vs.len()).then_before(m); + } + } + } + m +} + +fn run_align(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let s1 = point_req("align", p, "s1")?; + let d1 = point_req("align", p, "d1")?; + let pair2 = point_param(p, "s2").zip(point_param(p, "d2")); + let m = align_matrix(s1, d1, pair2, bool_or(p, "scale", false)); + let r = super::modify::transform_entities(s, &hs, &m, false)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +#[derive(Default)] +struct AlignM { + sel: SelectPhase, + objs: Vec, + pts: Vec, + ask_scale: bool, +} + +impl AlignM { + fn finish(&self, s: &mut Session, scale: bool) -> Result { + let (Some(s1), Some(d1)) = (self.pts.first().copied(), self.pts.get(1).copied()) else { return Ok(Step::Cancel) }; + let pair2 = self.pts.get(2).copied().zip(self.pts.get(3).copied()); + let m = align_matrix(s1, d1, pair2, scale); + super::modify::transform_entities(s, &self.objs, &m, false)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for AlignM { + fn name(&self) -> &'static str { + "ALIGN" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if !self.sel.done { + return self.sel.prompt(); + } + if self.ask_scale { + return Prompt::new("Scale objects based on alignment points?", curves::KW).kw(&["Yes", "No"]).default("N"); + } + match self.pts.len() { + 0 => Prompt::new("Specify first source point", Accept::POINT), + 1 => Prompt::new("Specify first destination point", Accept::POINT).base_opt(self.pts.first().copied()), + 2 => Prompt::new("Specify second source point", Accept::POINT), + 3 => Prompt::new("Specify second destination point", Accept::POINT).base_opt(self.pts.get(2).copied()), + _ => Prompt::new("Specify third source point or ", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(Step::Continue) + } + }; + } + if self.ask_scale { + let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y')); + return self.finish(s, yes); + } + match i { + Input::Point(p) => { + if self.pts.len() < 4 { + self.pts.push(p); + } else { + s.echo("Third pair ignored (2D align)."); + self.ask_scale = true; + } + Ok(Step::Continue) + } + Input::Enter => match self.pts.len() { + n if n >= 4 => { + self.ask_scale = true; + Ok(Step::Continue) + } + 2 | 3 => { + self.pts.truncate(2); + self.finish(s, false) + } + _ => Ok(Step::Cancel), + }, + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match self.pts.as_slice() { + [a] => vec![line(*a, c)], + [_, _, a] => vec![line(*a, c)], + _ => Vec::new(), + } + } +} + +// ===================================================================================== +// BLEND +// ===================================================================================== + +/// Endpoint of a curve nearest `pick` and the outward unit tangent there. +fn curve_end(k: &EntityKind, pick: Vec2) -> Option<(Vec2, Vec2)> { + let c = Chain::of(k)?; + if c.closed { + return None; + } + let (a, b) = (c.start()?, c.end()?); + if pick.dist(b) <= pick.dist(a) { + let t = c.segs.last()?.tangent(1.0); + Some((b, t)) + } else { + let t = c.segs.first()?.tangent(0.0); + Some((a, -t)) + } +} + +pub(crate) fn blend_spline(e1: (Vec2, Vec2), e2: (Vec2, Vec2), smooth: bool) -> Option { + let (p1, t1) = e1; + let (p2, t2) = e2; + let d = p1.dist(p2); + if d < 1e-12 { + return None; + } + let ctrl = if smooth { + let k = d / 5.0; + vec![p1, p1 + t1 * k, p1 + t1 * (2.0 * k), p2 + t2 * (2.0 * k), p2 + t2 * k, p2] + } else { + let k = d / 3.0; + vec![p1, p1 + t1 * k, p2 + t2 * k, p2] + }; + let deg = ctrl.len() - 1; + Some(Spline::from_control(ctrl, deg)) +} + +fn blend(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, smooth: bool) -> Result { + let k1 = curves::entity(s, h1)?.kind; + let k2 = curves::entity(s, h2)?.kind; + let e1 = curve_end(&k1, p1).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?; + let e2 = curve_end(&k2, p2).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?; + let sp = blend_spline(e1, e2, smooth).ok_or_else(|| other("The ends coincide."))?; + s.add_entity(EntityKind::Spline(sp)) +} + +fn run_blend(s: &mut Session, p: &Value) -> Result { + let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("blend", "`h1` is required"))?; + let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("blend", "`h2` is required"))?; + let p1 = point_req("blend", p, "p1")?; + let p2 = point_req("blend", p, "p2")?; + let smooth = str_param(p, "continuity").is_some_and(|c| c.eq_ignore_ascii_case("smooth")); + let h = blend(s, h1, p1, h2, p2, smooth)?; + Ok(json!({ "handle": h.hex() })) +} + +#[derive(Default)] +struct BlendM { + first: Option<(Handle, Vec2)>, + smooth: bool, + asking: bool, +} + +impl Interactive for BlendM { + fn name(&self) -> &'static str { + "BLEND" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo(format!("Continuity = {}", if self.smooth { "Smooth" } else { "Tangent" })); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking { + return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent"); + } + match self.first { + None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]), + Some(_) => Prompt::new("Select second point", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.smooth = k.to_ascii_lowercase().starts_with('s'); + } + self.asking = false; + return Ok(Step::Continue); + } + match i { + Input::Keyword(_) => { + self.asking = true; + Ok(Step::Continue) + } + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + match self.first { + None => { + let k = curves::entity(s, h)?.kind; + curve_end(&k, p).ok_or_else(|| other("Select an open curve."))?; + self.first = Some((h, p)); + Ok(Step::Continue) + } + Some((h1, p1)) => { + blend(s, h1, p1, h, p, self.smooth)?; + Ok(Step::Done) + } + } + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } +} + +// ===================================================================================== +// REVERSE / CHSPACE / FLATTEN (select, then act) +// ===================================================================================== + +pub(crate) fn reverse_kind(k: &EntityKind) -> Option { + Some(match k { + EntityKind::Line(l) => EntityKind::Line(cadcraft_doc::Line { a: l.b, b: l.a }), + EntityKind::LwPolyline(p) => EntityKind::LwPolyline(LwPolyline { vertices: curves::reverse_vertices(&p.vertices, p.closed), ..p.clone() }), + EntityKind::Polyline3d(p) => { + EntityKind::Polyline3d(cadcraft_doc::Polyline3d { points: p.points.iter().rev().copied().collect(), closed: p.closed }) + } + EntityKind::Spline(sp) => EntityKind::Spline(curves::reverse_spline(sp)), + _ => return None, + }) +} + +fn reverse(s: &mut Session, hs: &[Handle]) -> Result { + let mut n = 0; + for h in hs { + if curves::is_locked(s, *h) { + continue; + } + let Ok(e) = curves::entity(s, *h) else { continue }; + if let Some(k) = reverse_kind(&e.kind) { + set_kind(s, *h, k)?; + n += 1; + } + } + Ok(n) +} + +fn run_reverse(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = reverse(s, &hs)?; + Ok(json!({ "reversed": n })) +} + +/// Zero every z coordinate; 3D polylines become 2D polylines. +pub(crate) fn flatten_kind(k: &mut EntityKind) { + let z = |p: &mut cadcraft_geom::Vec3| p.z = 0.0; + match k { + EntityKind::Line(l) => { + z(&mut l.a); + z(&mut l.b); + } + EntityKind::Point(p) => z(&mut p.p), + EntityKind::Circle(c) => z(&mut c.center), + EntityKind::Arc(a) => z(&mut a.center), + EntityKind::Ellipse(e) => { + z(&mut e.center); + z(&mut e.major); + } + EntityKind::LwPolyline(p) => p.elevation = 0.0, + EntityKind::Polyline3d(p) => { + *k = lwpoly(p.points.iter().map(|q| PolyVertex::new(q.xy())).collect(), p.closed); + } + EntityKind::Ray(r) | EntityKind::XLine(r) => { + z(&mut r.base); + r.dir.z = 0.0; + r.dir = r.dir.normalized(); + } + EntityKind::Text(t) => { + z(&mut t.insert); + t.align_pt.iter_mut().for_each(z); + } + EntityKind::AttDef(a) => { + z(&mut a.text.insert); + a.text.align_pt.iter_mut().for_each(z); + } + EntityKind::MText(t) => z(&mut t.insert), + EntityKind::Insert(i) => { + z(&mut i.insert); + for a in &mut i.attribs { + z(&mut a.text.insert); + a.text.align_pt.iter_mut().for_each(z); + } + } + EntityKind::Dimension(d) => { + for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] { + z(p); + } + d.block = None; + } + EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z), + EntityKind::MLeader(m) => { + m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z)); + z(&mut m.landing); + } + EntityKind::Hatch(h) => h.elevation = 0.0, + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), + EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z), + EntityKind::Image(i) => { + z(&mut i.insert); + i.u.z = 0.0; + i.v.z = 0.0; + } + EntityKind::Table(t) => z(&mut t.insert), + EntityKind::Spline(_) | EntityKind::Viewport(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {} + } +} + +fn flatten(s: &mut Session, hs: &[Handle]) -> Result { + let mut n = 0; + for h in hs { + if curves::is_locked(s, *h) { + continue; + } + if s.doc()?.entity(*h).is_none() { + continue; + } + s.doc_mut()?.modify_entity(*h, |e| { + flatten_kind(&mut e.kind); + e.common.thickness = 0.0; + })?; + n += 1; + } + Ok(n) +} + +fn run_flatten(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = flatten(s, &hs)?; + Ok(json!({ "flattened": n })) +} + +/// Model↔paper transform through a viewport: paper = center + (model − viewCenter) / scale. +fn viewport_of(s: &Session, layout: &str, vp: Option) -> Option { + let d = s.doc().ok()?; + if let Some(h) = vp + && let Some(e) = d.entity(h) + && let EntityKind::Viewport(v) = &e.kind + { + return Some(v.clone()); + } + let st = d.space(&Space::Paper(layout.to_string()))?; + st.iter().find_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 && v.id != 1 => Some(v.clone()), + _ => None, + }) +} + +fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option) -> Result<(usize, Space, Space)> { + let from = s.space(); + let (target, layout) = match (&from, to) { + (Space::Model, t) => { + let name = match t { + Some(n) if !n.eq_ignore_ascii_case("model") => n.to_string(), + _ => { + let d = s.doc()?; + let mut ls: Vec<_> = d.layouts.iter().collect(); + ls.sort_by_key(|l| l.tab_order); + ls.first().map(|l| l.name.clone()).ok_or_else(|| other("No layout to move objects to."))? + } + }; + if s.doc()?.layout(&name).is_none() { + return Err(other(format!("Layout \"{name}\" not found."))); + } + let real = s.doc()?.layout(&name).map(|l| l.name.clone()).unwrap_or(name); + (Space::Paper(real.clone()), real) + } + (Space::Paper(n), _) => (Space::Model, n.clone()), + }; + let m = match viewport_of(s, &layout, vp) { + Some(v) => { + let k = v.view_height / v.height; + let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center)); + if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper } + } + None => Mat3::IDENTITY, + }; + let mut n = 0; + for h in hs { + if curves::is_locked(s, *h) { + continue; + } + let d = s.doc_mut()?; + if d.space_of(*h).as_ref() != Some(&from) { + continue; + } + let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; + if matches!(e.kind, EntityKind::Viewport(_)) { + continue; + } + let mut e = e; + e.kind.transform(&m); + d.remove_entity(*h); + if let Some(st) = d.space_mut(&target) { + st.push(e); + n += 1; + } + } + s.set_selection(Vec::new()); + Ok((n, from, target)) +} + +fn run_chspace(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?; + Ok(json!({ "moved": n, "to": match target { Space::Model => "model".to_string(), Space::Paper(n) => n } })) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelKind { + Reverse, + Chspace, + Flatten, +} + +struct SelOp { + kind: SelKind, + sel: SelectPhase, +} + +impl SelOp { + fn new(kind: SelKind) -> Self { + SelOp { kind, sel: SelectPhase::default() } + } + fn act(&self, s: &mut Session, hs: &[Handle]) -> Result { + match self.kind { + SelKind::Reverse => { + let n = reverse(s, hs)?; + s.echo(format!("{n} object(s) direction has been reversed.")); + } + SelKind::Flatten => { + let n = flatten(s, hs)?; + s.echo(format!("{n} object(s) flattened.")); + } + SelKind::Chspace => { + let (n, from, to) = chspace(s, hs, None, None)?; + let nm = |sp: &Space| if *sp == Space::Model { "MODEL" } else { "PAPER" }; + s.echo(format!("{n} object(s) changed from {} space to {} space.", nm(&from), nm(&to))); + } + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for SelOp { + fn name(&self) -> &'static str { + match self.kind { + SelKind::Reverse => "REVERSE", + SelKind::Chspace => "CHSPACE", + SelKind::Flatten => "FLATTEN", + } + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.act(s, &hs); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + self.sel.prompt() + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => self.act(s, &hs), + } + } +} + +// ===================================================================================== +// NCOPY +// ===================================================================================== + +/// The leaf objects of a block reference in world coordinates (nested references expanded). +fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) { + if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN { + return; + } + let Some(b) = d.block(&ins.block) else { return }; + let mm = m.then_before(ins.transform(b.base.xy())); + for e in b.entities.iter() { + match &e.kind { + EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out), + EntityKind::AttDef(_) => {} + k => { + let mut k = k.clone(); + k.transform(&mm); + out.push((e.common.clone(), k)); + } + } + } +} + +fn leaves_of(s: &Session, h: Handle) -> Result> { + let e = curves::entity(s, h)?; + let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) }; + let mut out = Vec::new(); + nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out); + Ok(out) +} + +fn nearest_leaf(leaves: Vec<(Common, EntityKind)>, p: Vec2) -> Option<(Common, EntityKind)> { + leaves.into_iter().min_by(|a, b| curves::kind_distance(&a.1, p).total_cmp(&curves::kind_distance(&b.1, p))) +} + +fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) -> Result> { + let space = s.space(); + let m = Mat3::translate(delta); + let mut out = Vec::new(); + for (c, k) in items { + let mut k = k.clone(); + k.transform(&m); + out.push(push_entity(s, &space, c.clone(), k)?); + } + Ok(out) +} + +fn run_ncopy(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?; + let leaves = leaves_of(s, h)?; + let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") { + Some(q) => nearest_leaf(leaves, q).into_iter().collect(), + None => leaves, + }; + if items.is_empty() { + return Err(other("The block reference has no nested objects.")); + } + let delta = match (point_param(p, "delta"), point_param(p, "from"), point_param(p, "to")) { + (Some(d), _, _) => d, + (None, Some(a), Some(b)) => b - a, + _ => Vec2::ZERO, + }; + let hs = place_copies(s, &items, delta)?; + Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })) +} + +#[derive(Default)] +struct NcopyM { + items: Vec<(Common, EntityKind)>, + selecting_done: bool, + base: Option, +} + +impl Interactive for NcopyM { + fn name(&self) -> &'static str { + "NCOPY" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.selecting_done, self.base) { + (false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]), + (true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), + (true, Some(b)) => Prompt::new("Specify second point or ", Accept::POINT).base(b), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.selecting_done { + return match i { + Input::Point(p) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?; + s.echo(format!("Nested object: {}", leaf.1.type_name())); + if self.items.len() < MAX_GEN { + self.items.push(leaf); + } + Ok(Step::Continue) + } + Input::Keyword(_) => { + s.echo("Insert mode: Copy (nested objects are copied as independent objects)."); + Ok(Step::Continue) + } + Input::Enter => { + if self.items.is_empty() { + return Ok(Step::Cancel); + } + self.selecting_done = true; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }; + } + match (self.base, i) { + (None, Input::Point(p)) => { + self.base = Some(p); + Ok(Step::Continue) + } + (None, Input::Enter | Input::Keyword(_)) => { + place_copies(s, &self.items, Vec2::ZERO)?; + Ok(Step::Done) + } + (Some(b), Input::Point(p)) => { + place_copies(s, &self.items, p - b)?; + Ok(Step::Done) + } + (Some(b), Input::Enter) => { + place_copies(s, &self.items, b)?; + Ok(Step::Done) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + let Some(b) = self.base else { return Vec::new() }; + let m = Mat3::translate(c - b); + self.items + .iter() + .take(500) + .map(|(_, k)| { + let mut k = k.clone(); + k.transform(&m); + k + }) + .collect() + } +} + +// ===================================================================================== +// FILLET extensions (arcs, circles, polylines) +// ===================================================================================== + +/// How a fillet operand is trimmed: the new kind and the arrival direction at the tangent point. +fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option, Option)> { + match k { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + let keep = if (a - t).dot(pick - t) >= (b - t).dot(pick - t) { a } else { b }; + let dir = (t - keep).normalized(); + if dir == Vec2::ZERO { + return Some((None, None)); + } + Some((Some(if keep == a { line(a, t) } else { line(t, b) }), Some(dir))) + } + EntityKind::Arc(ar) => { + let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end); + let ta = g.center.angle_to(t); + let ccw_tan = (t - g.center).perp().normalized(); + let ap = g.center.angle_to(pick); + let keep_start_side = if g.contains_angle(ta) { + // Keep the side of the tangent point that holds the pick. + cadcraft_geom::angle_in_sweep(ap, g.start, ta) + } else { + // Extend the nearer end. + ccw_sweep(g.end, ta) <= ccw_sweep(ta, g.start) + }; + if keep_start_side { + let na = Arc::new(g.center, g.radius, g.start, ta); + Some((Some(arc(&na)), Some(ccw_tan))) + } else { + let na = Arc::new(g.center, g.radius, ta, g.end); + Some((Some(arc(&na)), Some(-ccw_tan))) + } + } + EntityKind::Circle(_) => Some((None, None)), + _ => None, + } +} + +/// Fillet two curves (lines, arcs, circles) with radius `r`. Returns the new arc, if any. +pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result> { + let e1 = curves::entity(s, h1)?; + let e2 = curves::entity(s, h2)?; + let unsupported = || other("Fillet works on lines, arcs and circles (and polylines with the Polyline option)."); + let o1 = TanObj::from_kind(&e1.kind, p1).filter(|_| matches!(e1.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); + let o2 = TanObj::from_kind(&e2.kind, p2).filter(|_| matches!(e2.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); + let (Some(o1), Some(o2)) = (o1, o2) else { return Err(unsupported()) }; + if h1 == h2 { + return Err(other("Cannot fillet an object to itself.")); + } + let (t1, t2, fc) = if r <= 1e-12 { + // Sharp corner: the intersection nearest the picks. + let hits: Vec = match (o1, o2) { + (TanObj::Line(a), TanObj::Line(b)) => line_line_infinite(a.a, a.b, b.a, b.b).map(|x| vec![x.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(a), TanObj::Circle(b)) => cadcraft_geom::circle_circle(&a, &b), + }; + let x = hits + .into_iter() + .min_by(|a, b| (a.dist(p1) + a.dist(p2)).total_cmp(&(b.dist(p1) + b.dist(p2)))) + .ok_or_else(|| other("The objects do not intersect."))?; + (x, x, None) + } else { + let c = curves::ttr(o1, p1, o2, p2, r).ok_or_else(|| other("Radius is too large."))?; + (o1.touch(&c), o2.touch(&c), Some(c)) + }; + let (n1, d1) = trim_for_fillet(&e1.kind, p1, t1).ok_or_else(unsupported)?; + let (n2, d2) = trim_for_fillet(&e2.kind, p2, t2).ok_or_else(unsupported)?; + let new_arc = fc.map(|c| { + let a1 = c.center.angle_to(t1); + let a2 = c.center.angle_to(t2); + let ccw1 = (t1 - c.center).perp().normalized(); + let ccw2 = (t2 - c.center).perp().normalized(); + let ccw = match (d1, d2) { + (Some(d), _) => ccw1.dot(d) > 0.0, + (None, Some(d)) => ccw2.dot(-d) > 0.0, + _ => ccw_sweep(a1, a2) <= std::f64::consts::PI, + }; + if ccw { Arc::new(c.center, c.radius, a1, a2) } else { Arc::new(c.center, c.radius, a2, a1) } + }); + if let Some(k) = n1 { + set_kind(s, h1, k)?; + } + if let Some(k) = n2 { + set_kind(s, h2, k)?; + } + match new_arc { + Some(a) => { + let space = s.doc()?.space_of(h1).unwrap_or_else(|| s.space()); + Ok(Some(push_entity(s, &space, e1.common.clone(), arc(&a))?)) + } + None => Ok(None), + } +} + +/// Fillet every corner between two straight segments of a polyline. Returns the count. +pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result { + let e = curves::entity(s, h)?; + let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) }; + let vs = &pl.vertices; + let n = vs.len(); + if n < 3 { + return Ok(0); + } + let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); + // Distance cut back from each corner (0 = not filleted). + let mut cut = vec![0.0f64; n]; + let mut turn = vec![0.0f64; n]; + for (i, c) in cut.iter_mut().enumerate() { + if !pl.closed && (i == 0 || i + 1 == n) { + continue; + } + let prev = at(i + n - 1); + let cur = at(i); + let next = at(i + 1); + if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { + continue; + } + let u1 = (prev.p - cur.p).normalized(); + let u2 = (next.p - cur.p).normalized(); + if u1 == Vec2::ZERO || u2 == Vec2::ZERO { + continue; + } + let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; + if half < 1e-9 || (std::f64::consts::FRAC_PI_2 - half).abs() < 1e-9 { + continue; + } + *c = if r <= 1e-12 { 0.0 } else { r / half.tan() }; + if let Some(t) = turn.get_mut(i) { + *t = (-u1).cross(u2).atan2((-u1).dot(u2)); + } + } + // Drop fillets that do not fit their segments. + for i in 0..n { + let j = (i + 1) % n; + let seg = at(i).p.dist(at(j).p); + let (ci, cj) = (cut.get(i).copied().unwrap_or(0.0), cut.get(j).copied().unwrap_or(0.0)); + if ci + cj > seg + 1e-9 { + if let Some(x) = cut.get_mut(i) { + *x = 0.0; + } + if let Some(x) = cut.get_mut(j) { + *x = 0.0; + } + } + } + let mut out: Vec = Vec::with_capacity(n * 2); + let mut count = 0; + for i in 0..n { + let v = at(i); + let c = cut.get(i).copied().unwrap_or(0.0); + if c <= 1e-12 { + out.push(v); + continue; + } + let prev = at(i + n - 1).p; + let next = at(i + 1).p; + let t1 = v.p + (prev - v.p).normalized() * c; + let t2 = v.p + (next - v.p).normalized() * c; + let b = cadcraft_geom::arc_to_bulge(turn.get(i).copied().unwrap_or(0.0)); + out.push(PolyVertex { p: t1, bulge: b, ..v }); + out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); + count += 1; + } + set_kind(s, h, EntityKind::LwPolyline(LwPolyline { vertices: out, ..pl.clone() }))?; + Ok(count) +} + +// ===================================================================================== +// TRIM / EXTEND for ellipses and splines +// ===================================================================================== + +fn ellipse_of(e: &cadcraft_doc::Ellipse) -> Ellipse { + Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end } +} + +fn ellipse_kind(e: &Ellipse) -> EntityKind { + EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end }) +} + +/// Intersections of a sampled curve `eval(t)` (parameters `ts`) with the cutting segments, +/// as curve parameters refined by bisection on the exact curve. +fn hits_on(eval: &dyn Fn(f64) -> Vec2, ts: &[f64], cut: &[Segment]) -> Vec { + let mut out = Vec::new(); + for w in ts.windows(2) { + let (Some(t0), Some(t1)) = (w.first().copied(), w.get(1).copied()) else { continue }; + let (a, b) = (eval(t0), eval(t1)); + let sg = Segment::Line(Line::new(a, b)); + for c in cut { + for x in intersect_ext(&sg, c, false) { + let f = Line::new(a, b).param_of(x).clamp(0.0, 1.0); + out.push(refine_hit(eval, c, t0, t1, t0 + (t1 - t0) * f)); + } + } + } + out +} + +/// Signed side of `p` relative to a cutting segment (line side, or inside/outside an arc's circle). +fn side_of(c: &Segment, p: Vec2) -> f64 { + match c { + Segment::Line(l) => l.side(p) / l.len().max(1e-300), + Segment::Arc { arc, .. } => p.dist(arc.center) - arc.radius, + } +} + +fn refine_hit(eval: &dyn Fn(f64) -> Vec2, c: &Segment, t0: f64, t1: f64, guess: f64) -> f64 { + let (mut a, mut b) = (t0, t1); + let (mut fa, fb) = (side_of(c, eval(a)), side_of(c, eval(b))); + if !(fa.is_finite() && fb.is_finite()) || fa * fb > 0.0 { + return guess; + } + for _ in 0..60 { + let m = (a + b) / 2.0; + let fm = side_of(c, eval(m)); + if fa * fm <= 0.0 { + b = m; + } else { + a = m; + fa = fm; + } + } + (a + b) / 2.0 +} + +fn ellipse_samples(ge: &Ellipse) -> Vec { + let n = 720usize; + let sw = ge.sweep(); + (0..=n).map(|i| ge.start + sw * i as f64 / n as f64).collect() +} + +/// Trim objects that modify.rs does not handle itself (ellipses and splines). +pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result> { + let none = || other("Object does not intersect a cutting edge."); + match k { + EntityKind::Ellipse(e) => { + let ge = ellipse_of(e); + let full = Ellipse { start: 0.0, end: TAU, ..ge }; + let eval = |t: f64| full.at_param(t); + let params: Vec = hits_on(&eval, &ellipse_samples(&ge), cut).into_iter().map(norm_angle).collect(); + if ge.is_full() { + let mut ps = params; + ps.sort_by(f64::total_cmp); + ps.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + if ps.len() < 2 { + return Err(other("Ellipse must intersect cutting edges at two or more points.")); + } + let tp = full.param_of(pick); + let n = ps.len(); + for i in 0..n { + let (Some(a0), Some(a1)) = (ps.get(i).copied(), ps.get((i + 1) % n).copied()) else { continue }; + if cadcraft_geom::angle_in_sweep(tp, a0, a1) { + return Ok(vec![ellipse_kind(&Ellipse { start: a1, end: a0, ..ge })]); + } + } + return Err(none()); + } + let sweep = ge.sweep(); + let rel = |t: f64| norm_angle(t - ge.start); + let mut ts: Vec = params.into_iter().map(rel).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); + ts.sort_by(f64::total_cmp); + let tp = rel(full.param_of(pick)).min(sweep); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); + if lo == 0.0 && hi == sweep { + return Err(none()); + } + let mut v = Vec::new(); + if lo > 0.0 { + v.push(ellipse_kind(&Ellipse { end: norm_angle(ge.start + lo), ..ge })); + } + if hi < sweep { + v.push(ellipse_kind(&Ellipse { start: norm_angle(ge.start + hi), ..ge })); + } + Ok(v) + } + EntityKind::Spline(sp) => { + if !sp.is_valid() { + return Err(other("Invalid spline.")); + } + let (lo_d, hi_d) = sp.domain(); + let n = 512; + let params: Vec = (0..=n).map(|i| lo_d + (hi_d - lo_d) * i as f64 / n as f64).collect(); + let eval = |t: f64| sp.eval(t); + let mut ts: Vec = + hits_on(&eval, ¶ms, cut).into_iter().filter(|t| *t > lo_d + 1e-9 * (hi_d - lo_d) && *t < hi_d - 1e-9 * (hi_d - lo_d)).collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let tp = curves::spline_param_of(sp, pick); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min); + if lo == lo_d && hi == hi_d { + return Err(none()); + } + let mut v = Vec::new(); + if lo > lo_d + && let Some(a) = curves::sub_spline(sp, lo_d, lo) + { + v.push(EntityKind::Spline(a)); + } + if hi < hi_d + && let Some(b) = curves::sub_spline(sp, hi, hi_d) + { + v.push(EntityKind::Spline(b)); + } + Ok(v) + } + _ => Err(other("Cannot trim that object.")), + } +} + +/// Extend objects that modify.rs does not handle itself (elliptical arcs). +pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result { + match k { + EntityKind::Ellipse(e) => { + let ge = ellipse_of(e); + if ge.is_full() { + return Err(other("Cannot extend a closed ellipse.")); + } + let full = Ellipse { start: 0.0, end: TAU, ..ge }; + let from_end = pick.dist(ge.at_param(ge.end)) < pick.dist(ge.at_param(ge.start)); + let eval = |t: f64| full.at_param(t); + let hits: Vec = hits_on(&eval, &ellipse_samples(&full), cut) + .into_iter() + .map(norm_angle) + .filter(|t| !cadcraft_geom::angle_in_sweep(*t, ge.start, ge.end)) + .collect(); + let t = if from_end { + hits.iter().copied().min_by(|x, y| ccw_sweep(ge.end, *x).total_cmp(&ccw_sweep(ge.end, *y))) + } else { + hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ge.start).total_cmp(&ccw_sweep(*y, ge.start))) + }; + let t = t.ok_or_else(|| other("Object does not intersect an edge."))?; + Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } })) + } + _ => Err(other("Cannot extend that object.")), + } +} + +// ===================================================================================== +// ARRAYPATH (non-associative copies along a curve) +// ===================================================================================== + +pub(crate) fn arraypath( + s: &mut Session, + hs: &[Handle], + path: Handle, + count: Option, + spacing: Option, + align: bool, +) -> Result> { + let pk = curves::entity(s, path)?.kind; + let c = Chain::of(&pk).ok_or_else(|| other("Select a line, arc, circle, polyline, ellipse or spline as the path."))?; + let l = c.len(); + if l <= 1e-12 { + return Err(other("The path has no length.")); + } + let objs: Vec = hs.iter().copied().filter(|h| *h != path).collect(); + if objs.is_empty() { + return Err(other("Select objects to array.")); + } + let (n, step) = match (count, spacing) { + (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize + 1).clamp(1, MAX_GEN), sp), + (Some(n), _) => { + let n = n.clamp(1, MAX_GEN); + (n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 }) + } + _ => (6, if c.closed { l / 6.0 } else { l / 5.0 }), + }; + let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) }; + let mut out = Vec::new(); + for i in 1..n { + let Some((p, t)) = c.at_length(step * i as f64) else { continue }; + let mut m = Mat3::translate(p - p0); + if align { + m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m); + } + out.extend(super::modify::transform_entities(s, &objs, &m, true)?); + } + Ok(out) +} + +fn run_arraypath(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?; + let count = p.get("count").and_then(Value::as_u64).map(|n| n.min(MAX_GEN as u64) as usize); + let spacing = p.get("spacing").and_then(Value::as_f64); + let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +#[derive(Default)] +struct PathArrayM { + sel: SelectPhase, + objs: Vec, + path: Option, +} + +impl Interactive for PathArrayM { + fn name(&self) -> &'static str { + "ARRAYPATH" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if !self.sel.done { + return self.sel.prompt(); + } + match self.path { + None => Prompt::new("Select path curve", Accept::POINT), + Some(_) => Prompt::new("Enter number of items along path", Accept::NUMBER).default("6"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(Step::Continue) + } + }; + } + match (self.path, i) { + (None, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + Chain::of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a curve as the path."))?; + self.path = Some(h); + Ok(Step::Continue) + } + (Some(path), inp @ (Input::Text(_) | Input::Enter)) => { + let n = match &inp { + Input::Text(t) => { + number(t).filter(|n| *n >= 1.0 && *n <= MAX_GEN as f64).ok_or_else(|| other("Requires a count of 1 or more."))? as usize + } + _ => 6, + }; + arraypath(s, &self.objs, path, Some(n), None, true)?; + s.set_selection(Vec::new()); + s.echo(format!("Type = Path Associative = No ({n} items)")); + Ok(Step::Done) + } + (None, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } +} + +// ===================================================================================== +// TEXTEDIT +// ===================================================================================== + +fn text_of(k: &EntityKind) -> Option { + match k { + EntityKind::Text(t) => Some(t.value.clone()), + EntityKind::AttDef(a) => Some(a.text.value.clone()), + EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")), + EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }), + _ => None, + } +} + +pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> { + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } + let mut k = curves::entity(s, h)?.kind; + match &mut k { + EntityKind::Text(t) => t.value = text.to_string(), + EntityKind::AttDef(a) => a.text.value = text.to_string(), + EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"), + EntityKind::Dimension(d) => { + d.text = if text == "<>" { String::new() } else { text.to_string() }; + d.block = None; + } + _ => return Err(other("Select a text, mtext, attribute definition or dimension.")), + } + set_kind(s, h, k) +} + +fn run_textedit(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("textedit", "`handle` is required"))?; + let text = str_param(p, "text").ok_or_else(|| bad("textedit", "`text` is required"))?; + if text.len() > 1_000_000 { + return Err(bad("textedit", "text is too long")); + } + set_text(s, h, text)?; + Ok(json!({ "handle": h.hex() })) +} + +#[derive(Default)] +struct TextEditM { + h: Option<(Handle, String)>, +} + +impl Interactive for TextEditM { + fn name(&self) -> &'static str { + "TEXTEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match &self.h { + None => Prompt::new("Select an annotation object or", Accept::POINT).kw(&["Undo", "Mode"]), + Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (&self.h, i) { + (None, Input::Point(p)) => { + let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; + let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?; + self.h = Some((h, old)); + Ok(Step::Continue) + } + (Some((h, _)), Input::Text(t) | Input::Keyword(t)) => { + set_text(s, *h, &t)?; + self.h = None; + Ok(Step::Continue) + } + (Some(_), Input::Enter) => { + self.h = None; + Ok(Step::Continue) + } + (None, Input::Enter) => Ok(Step::Done), + (None, Input::Keyword(_)) => Ok(Step::Continue), + _ => Ok(Step::Continue), + } + } +} + +#[cfg(test)] +#[path = "modify2_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs new file mode 100644 index 00000000..dfcf219f --- /dev/null +++ b/crates/engine/src/cmd/modify2_tests.rs @@ -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")); +} diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs new file mode 100644 index 00000000..f0e69934 --- /dev/null +++ b/crates/engine/src/cmd/utility.rs @@ -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 { + 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, + depth: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) { + self.i += 1; + } + } + fn peek(&mut self) -> Option { + self.ws(); + self.s.get(self.i).copied() + } + fn expr(&mut self) -> std::result::Result { + 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 { + 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 { + 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 { + 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 { + 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::().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) -> std::result::Result { + 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 { + 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 { + 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 = d.space(&space).map(|st| st.handles()).unwrap_or_default(); + let mut found: Vec = 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 = 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> { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs new file mode 100644 index 00000000..46a42057 --- /dev/null +++ b/crates/engine/src/grips.rs @@ -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 { + 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 { + 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 { + 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> { + 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; diff --git a/crates/engine/src/grips_tests.rs b/crates/engine/src/grips_tests.rs new file mode 100644 index 00000000..1cf720f6 --- /dev/null +++ b/crates/engine/src/grips_tests.rs @@ -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); + } + } + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6a8ae51c..5ba4e2b3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 7e8234e3..5d8f3e6a 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -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> { 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)> + 'a>; + +pub(crate) fn bounds_of(d: &Drawing, e: &Entity, known: Option) -> 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>, + query: &Bounds2, + infinite: bool, + rev: bool, +) -> Option> { + 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 { - 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>, p: Vec2, aperture: f64) -> Option { 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 { - 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>, bx: Bounds2, crossing: bool) -> Vec { + 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 { - 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>, fence: &[Vec2]) -> Vec { 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); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 97c8228d..92ecc0c1 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -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>, + cursor: Vec2, + aperture: f64, + osmode: u32, + base: Option, +) -> Option { + 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 = 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; diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs new file mode 100644 index 00000000..97a41de9 --- /dev/null +++ b/crates/engine/src/spatial.rs @@ -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, u32>; + +/// An R-tree over one space's entity bounds. +pub struct SpatialIndex { + store: EntityStore, + blocks: BTreeMap>, + dim_styles: Vec, + dimscale: f64, + ents: Vec>, + bounds: Vec, + tree: RTree, + /// Draw indices of xlines and rays (unbounded; always candidates for pick and crossing). + infinite: Vec, +} + +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> { + 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> { + 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 = 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>> = 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> { + 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 { + { + 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> { + 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 = (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()); + } +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index ad4e34e6..e088012d 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -10,3 +10,4 @@ workspace = true [dependencies] cadcraft-geom = { workspace = true } +skrifa = { workspace = true } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index fa838c33..6df33796 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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()); + } +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs new file mode 100644 index 00000000..e02b0923 --- /dev/null +++ b/crates/fonts/src/ttf.rs @@ -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>>; + +fn db() -> &'static Mutex> { + static DB: OnceLock>> = 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 = 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>> = 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) { + 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>> { + 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>, + cur: Vec, + 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 { + 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 }) +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index de71bddb..2eb76001 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -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" diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 721c8dce..3b3d70e7 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -674,7 +674,7 @@ pub fn read(bytes: &[u8]) -> Result { d.layouts.clear(); let mut block_records: HashMap = HashMap::new(); let mut blocks: Vec<(String, Vec3, Vec<(Option, 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 { 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 { // Layouts. let mut paper_block_for: HashMap = 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 { } 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 +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 2d98b3b6..da527439 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -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]), diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e1a7c848..7a2387fc 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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> { "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())), } } diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs new file mode 100644 index 00000000..1ee0049a --- /dev/null +++ b/crates/io/src/pdf.rs @@ -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, + /// Custom paper size in millimetres (portrait width, height). + pub paper_mm: Option<(f64, f64)>, + pub landscape: Option, + /// Fit the drawing to the printable area. Default: on for model space, off for layouts (1:1). + pub fit: Option, + /// Plot scale in paper units per drawing unit (when not fitting). + pub scale: Option, + /// Plot object lineweights. Default: the layout's setting; on for model space. + pub lineweights: Option, + /// 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> { + 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 { + 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> { + 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 { + 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 = None; + let mut fill: Option = None; + let mut width: Option = 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 = 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 { + let mut out: Vec = Vec::with_capacity(content.len() + 1024); + out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); + let mut offsets: Vec = Vec::new(); + let mut obj = |out: &mut Vec, 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 +} diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 7db12cdc..820bf88c 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -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 = 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); +} diff --git a/crates/render/src/clip.rs b/crates/render/src/clip.rs new file mode 100644 index 00000000..dbee9cf0 --- /dev/null +++ b/crates/render/src/clip.rs @@ -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> { + let mut out: Vec> = 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 = 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 { + if r.is_empty() { + return Vec::new(); + } + let mut cur: Vec = 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 { + 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)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 38175a14..ef93ae07 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, pub tris: Vec, 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, } 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>(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, f64, bool) { +fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option, 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, } 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, } } diff --git a/crates/render/src/paper.rs b/crates/render/src/paper.rs new file mode 100644 index 00000000..a922045d --- /dev/null +++ b/crates/render/src/paper.rs @@ -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::().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 { + 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()); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0bb9f866..718e0b69 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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 = 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()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5fd7ef9b..ca4524ed 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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"] } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 00000000..e475b2e1 --- /dev/null +++ b/crates/ui-egui/build.rs @@ -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}"); + } + } +} diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs new file mode 100644 index 00000000..befd7015 --- /dev/null +++ b/crates/ui-egui/src/about.rs @@ -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> { + 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, + pub real: Option, + 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 { + let mut by: BTreeMap = 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::().ok()).unwrap_or(0); + p.removed += r.get(5).and_then(|v| v.parse::().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 = 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, usize) { + let meta: BTreeMap = 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 = 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::().ok()).unwrap_or(0); + m.removed += r.get(5).and_then(|v| v.parse::().ok()).unwrap_or(0); + } + } + (by.into_values().collect(), all.len()) +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 475dac6e..e4f1736c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -38,7 +38,17 @@ impl Xf { #[derive(Default)] pub struct CanvasState { pub list: Option, - 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, + /// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]). + mesh_key: Option, + mesh_slot: crate::gpu::MeshSlot, + mesh_origin: Vec2, + /// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode). + infinite: Vec, + /// Milliseconds spent converting the display list to vertex data (last rebuild). + pub mesh_ms: f64, pub rect: Option, pub xf: Option, pub hover: Option, @@ -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) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8f32db8c..4a4acdf5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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())}, }) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 24912850..d9a1f76a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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::(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::(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::(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(); } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs new file mode 100644 index 00000000..d506cddc --- /dev/null +++ b/crates/ui-egui/src/gpu.rs @@ -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, + pub tris: Vec, + pub quads: Vec, +} + +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, 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, 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 { + 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>>; + +/// 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, + lines: Vec, + tris: Vec, + quads: Vec, +} + +const UNIFORM_SIZE: u64 = 48; + +const SHADER: &str = r#" +struct View { + a: vec4, // offset x, offset y (world), scale (device px per unit), 1 = linear output + b: vec4, // canvas centre x, y (device px), screen w, h (device px) + c: vec4, // pixels per point +}; +@group(0) @binding(0) var view: View; + +struct VOut { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +fn to_px(p: vec2) -> vec2 { + let w = (p + view.a.xy) * view.a.z; + return vec2(view.b.x + w.x, view.b.y - w.y); +} + +fn px_to_clip(px: vec2) -> vec4 { + return vec4(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) -> vec4 { + if (view.a.w > 0.5) { + let lo = c.rgb / 12.92; + let hi = pow((c.rgb + vec3(0.055)) / 1.055, vec3(2.4)); + return vec4(select(hi, lo, c.rgb <= vec3(0.04045)), c.a); + } + return c; +} + +@vertex +fn vs_line(@location(0) pos: vec2, @location(1) color: vec4) -> 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, + @location(1) b: vec2, + @location(2) color: vec4, + @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(1.0, 0.0); + if (len > 0.0001) { + dir = d / len; + } + let n = vec2(-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 { + 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 { + 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 { + let Some(res) = resources.get_mut::() 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 = 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::() 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()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 27bf0826..d7665a88 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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) -> Self { self.control_rx = Some(rx); self diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..ade22ddd --- /dev/null +++ b/docs/mcp.md @@ -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. diff --git a/docs/parity.md b/docs/parity.md index 66331ea6..24a0dcd9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 diff --git a/xtask/src/contributors.rs b/xtask/src/contributors.rs new file mode 100644 index 00000000..adf9ddc7 --- /dev/null +++ b/xtask/src/contributors.rs @@ -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 { + 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 { + 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 = 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 = 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 = 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::().unwrap_or(0); + c.4 += p[1].parse::().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(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 92a3108e..dd9713df 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 ... 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(())