+ A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
+ dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
+ Windows, Linux and FreeBSD, and in the browser via WebAssembly.
+ By the ArtCraft team.
+
+
+ | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
+| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
+| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
+| | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
+| | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
+| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
+| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
+| | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
+
+And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+
+
+
+
+
+
+
+Come make things with us
+
+
+ Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
+ set type, and people still figuring out what they like to make. Share what you're working on,
+ ask for help, tell us what's broken, or tell us what you wish these tools could do.
+ Whatever your medium and however long you've been at it, you're welcome here.
+
+
+
+ discord.gg/artcraft ·
+ getartcraft.com ·
+ The Crafting Apps ·
+ CADCraft
+
+
+## License and credits
+
+CADCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
+Copyright (c) 2026 ArtCraft Team and the CADCraft contributors. Required notices are in [NOTICE](NOTICE).
+
+Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
+with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
+
+CADCraft's icons, its single-stroke drafting font, its hatch patterns and its linetypes are all
+original work, drawn or defined in code. The sample drawings are generated in code too.
+
+The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
+ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
+part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
+Forks and modified versions must remove them.
+
+Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.
+
+
+
+ Made by the ArtCraft team and community.
+
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 00000000..8bac59b3
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,52 @@
+# CADCraft roadmap
+
+Status as of **2026-10-07**. CADCraft targets full parity with AutoCAD (2D drafting first, then
+annotation, layouts and plotting, DWG, parametrics and 3D), plus things AutoCAD doesn't have:
+agent control over MCP, a scriptable CLI, a web build and a free licence.
+
+## Where we are
+
+| 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%** |
+| 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 |
+
+The weighted estimate counts depth: most covered menu items work in their common forms but lack
+some options (e.g. ARC has 3-point and start-center-end but not all eleven variants; TRIM works on
+lines, arcs, circles and polylines but not ellipses or splines; dimensions render but have no
+interactive DIM commands yet).
+
+## Milestones
+
+| # | 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 |
+| 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 |
+| 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 |
+| 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** |
+
+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.
+
+## Current focus
+
+1. Interactive DIM commands (DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMCONTINUE, DIMBASELINE).
+2. HATCH with pick-point boundary detection; BLOCK / INSERT.
+3. Grip editing.
+4. Release pipeline (signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm, FreeBSD, web).
+5. GPU canvas for large drawings.
diff --git a/apps/cadcraft-cli/Cargo.toml b/apps/cadcraft-cli/Cargo.toml
new file mode 100644
index 00000000..b852183f
--- /dev/null
+++ b/apps/cadcraft-cli/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "cadcraft-cli"
+description = "CADCraft command-line tool: info, convert, run scripts, export, commands, MCP server"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+cadcraft-engine = { workspace = true }
+cadcraft-io = { workspace = true }
+cadcraft-mcp = { workspace = true }
+cadcraft-render = { workspace = true }
+serde_json = { workspace = true }
diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs
new file mode 100644
index 00000000..f29b4a0d
--- /dev/null
+++ b/apps/cadcraft-cli/src/main.rs
@@ -0,0 +1,159 @@
+//! `cadcraft-cli`: headless CADCraft.
+//!
+//! ```text
+//! cadcraft-cli info FILE.dxf summary as JSON
+//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) convert / export
+//! 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)
+//! ```
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+use std::process::ExitCode;
+
+use cadcraft_engine::Session;
+use serde_json::{Value, json};
+
+const USAGE: &str = "usage:
+ cadcraft-cli info FILE.dxf
+ cadcraft-cli convert IN.dxf OUT.(dxf|svg|png)
+ 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 --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()),
+ });
+}
+
+fn open(s: &mut Session, path: &str) -> Result<(), String> {
+ s.execute("open", &json!({ "path": path })).map(|_| ()).map_err(|e| e.to_string())
+}
+
+fn info(path: &str) -> Result<(), String> {
+ let mut s = Session::empty();
+ open(&mut s, path)?;
+ let v = s.execute("drawing.inspect", &json!({ "entities": false })).map_err(|e| e.to_string())?;
+ println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
+ Ok(())
+}
+
+fn export(s: &Session, out: &str) -> Result<(), String> {
+ let d = s.doc().map_err(|e| e.to_string())?;
+ let bytes = cadcraft_io::write(d, out).map_err(|e| e.to_string())?;
+ std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
+ eprintln!("wrote {out} ({} bytes)", bytes.len());
+ Ok(())
+}
+
+fn run(args: &[String]) -> Result<(), String> {
+ let mut s = Session::empty();
+ let mut it = args.iter();
+ let mut save = None;
+ let mut exp = None;
+ let mut steps: Vec<(String, String)> = Vec::new();
+ while let Some(a) = it.next() {
+ match a.as_str() {
+ "--sample" => {
+ s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
+ }
+ "--metric" => {
+ s.new_drawing(true);
+ }
+ "--script" => steps.push(("script".into(), it.next().ok_or("--script needs text")?.replace("\\n", "\n"))),
+ "--script-file" => {
+ let p = it.next().ok_or("--script-file needs a path")?;
+ steps.push(("script".into(), std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?));
+ }
+ "--cmd" => steps.push(("cmd".into(), it.next().ok_or("--cmd needs `id {json}`")?.clone())),
+ "--save" => save = Some(it.next().ok_or("--save needs a path")?.clone()),
+ "--export" => exp = Some(it.next().ok_or("--export needs a path")?.clone()),
+ f if !f.starts_with("--") => open(&mut s, f)?,
+ other => return Err(format!("unknown option {other}")),
+ }
+ }
+ if s.docs.is_empty() {
+ s.new_drawing(false);
+ }
+ for (kind, body) in steps {
+ match kind.as_str() {
+ "script" => s.script(&body).map_err(|e| e.to_string())?,
+ _ => {
+ let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
+ let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
+ let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
+ if !r.is_null() {
+ println!("{r}");
+ }
+ }
+ }
+ }
+ for l in &s.log {
+ eprintln!("{l}");
+ }
+ if let Some(p) = save {
+ export(&s, &p)?;
+ }
+ if let Some(p) = exp {
+ export(&s, &p)?;
+ }
+ Ok(())
+}
+
+fn main() -> ExitCode {
+ install_io();
+ let args: Vec = std::env::args().skip(1).collect();
+ let rest: Vec = args.iter().skip(1).cloned().collect();
+ let r = match args.first().map(String::as_str) {
+ Some("info") => rest.first().ok_or_else(|| USAGE.to_string()).and_then(|p| info(p)),
+ Some("convert") => match rest.as_slice() {
+ [i, o] => {
+ let mut s = Session::empty();
+ open(&mut s, i).and_then(|_| export(&s, o))
+ }
+ _ => Err(USAGE.into()),
+ },
+ Some("run") => run(&rest),
+ Some("commands") => {
+ let s = Session::new();
+ let f = rest.first().map(|x| x.to_ascii_lowercase());
+ let v: Vec = cadcraft_engine::command_specs()
+ .iter()
+ .filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
+ .map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default())
+ .collect();
+ println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
+ Ok(())
+ }
+ Some("mcp") => {
+ let backend: Box = match rest.iter().position(|a| a == "--connect").and_then(|i| rest.get(i + 1)) {
+ Some(addr) => match cadcraft_mcp::Remote::connect(addr) {
+ Ok(r) => Box::new(r),
+ Err(e) => {
+ eprintln!("cadcraft-cli: cannot connect to {addr}: {e}");
+ return ExitCode::FAILURE;
+ }
+ },
+ None => Box::new(cadcraft_mcp::Headless::default()),
+ };
+ let stdin = std::io::stdin();
+ let stdout = std::io::stdout();
+ cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
+ }
+ Some("--version" | "-V") => {
+ println!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
+ Ok(())
+ }
+ _ => Err(USAGE.into()),
+ };
+ match r {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("{e}");
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml
new file mode 100644
index 00000000..c966303b
--- /dev/null
+++ b/apps/cadcraft-web/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "cadcraft-web"
+description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "cadcraft-web"
+path = "src/main.rs"
+
+[dependencies]
+cadcraft-engine = { workspace = true }
+cadcraft-ui-egui = { workspace = true }
+cadcraft-io = { workspace = true }
+egui = { workspace = true }
+log = { workspace = true }
+serde_json = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] }
diff --git a/apps/cadcraft-web/Trunk.toml b/apps/cadcraft-web/Trunk.toml
new file mode 100644
index 00000000..9e6dd371
--- /dev/null
+++ b/apps/cadcraft-web/Trunk.toml
@@ -0,0 +1,10 @@
+# Build with: trunk build --release (from apps/cadcraft-web)
+[build]
+target = "index.html"
+dist = "../../dist/web"
+public_url = "./"
+filehash = true
+
+[serve]
+addresses = ["127.0.0.1"]
+port = 8771
diff --git a/apps/cadcraft-web/index.html b/apps/cadcraft-web/index.html
new file mode 100644
index 00000000..c8e2e960
--- /dev/null
+++ b/apps/cadcraft-web/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ CADCraft
+
+
+
+
+
+ Loading CADCraft…
+
+
diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs
new file mode 100644
index 00000000..79adfa1f
--- /dev/null
+++ b/apps/cadcraft-web/src/main.rs
@@ -0,0 +1,84 @@
+//! CADCraft in the browser.
+//!
+//! Runs the same [`cadcraft_ui_egui::CadApp`] as the desktop app through eframe's web runner
+//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
+//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the sample
+//! drawing.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+#[cfg(target_arch = "wasm32")]
+mod web {
+ use cadcraft_engine::Session;
+ use cadcraft_ui_egui::{CadApp, Services};
+ use wasm_bindgen::JsCast as _;
+
+ const CANVAS_ID: &str = "cadcraft_canvas";
+ const LOADING_ID: &str = "cadcraft_loading";
+
+ struct Shell(CadApp);
+
+ impl eframe::App for Shell {
+ fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ self.0.logic(ctx);
+ }
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ self.0.ui(ui);
+ }
+ }
+
+ fn query() -> String {
+ web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
+ }
+
+ pub fn start() {
+ eframe::WebLogger::init(log::LevelFilter::Info).ok();
+ 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()),
+ });
+ wasm_bindgen_futures::spawn_local(async {
+ let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
+ let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else {
+ log::error!("missing ");
+ return;
+ };
+ let mut options = eframe::WebOptions::default();
+ if query().contains("webgl")
+ && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
+ {
+ create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
+ }
+ let result = eframe::WebRunner::new()
+ .start(
+ canvas,
+ options,
+ Box::new(move |_cc| {
+ let mut app = CadApp::new(Session::new(), Services::default());
+ if query().contains("sample") {
+ let _ = app.run("ui.sample", serde_json::json!({}));
+ }
+ Ok(Box::new(Shell(app)))
+ }),
+ )
+ .await;
+ if let Some(el) = document.get_element_by_id(LOADING_ID) {
+ match result {
+ Ok(()) => el.remove(),
+ Err(e) => {
+ el.set_inner_html(&format!("CADCraft failed to start: {e:?}
A browser with WebGPU or WebGL2 is required.
"))
+ }
+ }
+ }
+ });
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+ web::start();
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+ eprintln!("cadcraft-web only runs in the browser: build it with `trunk build --release` in apps/cadcraft-web");
+}
diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml
new file mode 100644
index 00000000..ceeedd21
--- /dev/null
+++ b/apps/cadcraft/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "cadcraft"
+description = "CADCraft desktop app"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+default-run = "cadcraft"
+
+[lints]
+workspace = true
+
+[dependencies]
+cadcraft-engine = { workspace = true }
+cadcraft-ui-egui = { workspace = true }
+cadcraft-io = { workspace = true }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
+egui = { workspace = true }
+serde_json = { workspace = true }
+log = { workspace = true }
+
+[build-dependencies]
+winresource = "0.1"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+rfd = { workspace = true }
+
+[target.'cfg(target_os = "macos")'.dependencies]
+muda = { version = "0.21", default-features = false }
diff --git a/apps/cadcraft/build.rs b/apps/cadcraft/build.rs
new file mode 100644
index 00000000..1349ebe3
--- /dev/null
+++ b/apps/cadcraft/build.rs
@@ -0,0 +1,28 @@
+//! Windows only: embed the app icon and version info into `cadcraft.exe`. Elsewhere a no-op.
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=../../assets/app-icon/cadcraft.ico");
+ println!("cargo:rerun-if-env-changed=CADCRAFT_REQUIRE_WINRES");
+ println!("cargo:rerun-if-env-changed=CADCRAFT_BUILD_SHA");
+ if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
+ return;
+ }
+ let mut res = winresource::WindowsResource::new();
+ if std::path::Path::new("../../assets/app-icon/cadcraft.ico").exists() {
+ res.set_icon("../../assets/app-icon/cadcraft.ico");
+ }
+ res.set("ProductName", "CADCraft")
+ .set("FileDescription", "CADCraft computer-aided design")
+ .set("CompanyName", "Learning Machines LLC")
+ .set("LegalCopyright", "Copyright (c) 2026 ArtCraft Team and the CADCraft contributors. MIT OR Apache-2.0.")
+ .set("OriginalFilename", "cadcraft.exe")
+ .set("InternalName", "cadcraft");
+ if let Err(e) = res.compile() {
+ if std::env::var_os("CADCRAFT_REQUIRE_WINRES").is_some() {
+ eprintln!("embedding Windows resources failed: {e}");
+ std::process::exit(1);
+ }
+ println!("cargo:warning=cadcraft.exe built without icon/version resources: {e}");
+ }
+}
diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs
new file mode 100644
index 00000000..ff519aa6
--- /dev/null
+++ b/apps/cadcraft/src/control_server.rs
@@ -0,0 +1,62 @@
+//! Loopback JSON-lines control server: one request per line, one reply per line.
+//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
+
+use std::io::{BufRead, BufReader, Write};
+use std::net::{TcpListener, TcpStream};
+use std::sync::mpsc::{Receiver, Sender, channel};
+use std::time::Duration;
+
+use cadcraft_ui_egui::ControlRequest;
+use serde_json::{Value, json};
+
+pub fn start(port: u16, ctx: egui::Context) -> Receiver {
+ let (tx, rx) = channel::();
+ let listener = match TcpListener::bind(("127.0.0.1", port)) {
+ Ok(l) => l,
+ Err(e) => {
+ eprintln!("cadcraft: control server failed to bind 127.0.0.1:{port}: {e}");
+ return rx;
+ }
+ };
+ eprintln!("cadcraft: control server listening on 127.0.0.1:{port}");
+ std::thread::spawn(move || {
+ for stream in listener.incoming().flatten() {
+ let tx = tx.clone();
+ let ctx = ctx.clone();
+ std::thread::spawn(move || serve(stream, tx, ctx));
+ }
+ });
+ rx
+}
+
+fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
+ let Ok(read) = stream.try_clone() else { return };
+ let mut out = stream;
+ for line in BufReader::new(read).lines() {
+ let Ok(line) = line else { break };
+ if line.trim().is_empty() {
+ continue;
+ }
+ let reply = match serde_json::from_str::(&line) {
+ Ok(msg) => {
+ let id = msg.get("id").cloned().unwrap_or(Value::Null);
+ let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
+ let params = msg.get("params").cloned().unwrap_or(json!({}));
+ let (req, rrx) = ControlRequest::new(method, params);
+ if tx.send(req).is_err() {
+ break;
+ }
+ ctx.request_repaint();
+ let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
+ if let Some(o) = r.as_object_mut() {
+ o.insert("id".into(), id);
+ }
+ r
+ }
+ Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
+ };
+ if writeln!(out, "{reply}").is_err() {
+ break;
+ }
+ }
+}
diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs
new file mode 100644
index 00000000..9f77d995
--- /dev/null
+++ b/apps/cadcraft/src/main.rs
@@ -0,0 +1,143 @@
+//! CADCraft desktop app.
+//!
+//! Usage: `cadcraft [--control ] [--sample] [files…]`
+//!
+//! `--control ` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
+//! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`.
+//! See `cadcraft_ui_egui::control` for the methods.
+#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+mod control_server;
+#[cfg(target_os = "macos")]
+mod native_menu;
+
+use cadcraft_engine::Session;
+use cadcraft_ui_egui::{CadApp, Services};
+
+struct App(CadApp, #[cfg(target_os = "macos")] Option);
+
+impl eframe::App for App {
+ fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ #[cfg(target_os = "macos")]
+ {
+ if self.1.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
+ self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
+ }
+ if let Some(m) = &mut self.1 {
+ m.poll(&mut self.0, ctx);
+ }
+ }
+ self.0.logic(ctx);
+ if self.0.quit_requested {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+ }
+ }
+ fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
+ self.0.raw_input_hook(raw);
+ }
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ self.0.ui(ui);
+ }
+}
+
+/// File format hooks for the engine (it stays I/O-agnostic).
+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()),
+ });
+}
+
+fn services() -> Services {
+ Services {
+ pick_open: Some(Box::new(|| {
+ rfd::FileDialog::new()
+ .add_filter("Drawing (DXF)", &["dxf"])
+ .add_filter("All files", &["*"])
+ .pick_file()
+ .map(|p| p.to_string_lossy().to_string())
+ })),
+ pick_save: Some(Box::new(|name: &str| {
+ rfd::FileDialog::new()
+ .set_file_name(name)
+ .add_filter("Drawing (DXF)", &["dxf"])
+ .add_filter("SVG", &["svg"])
+ .add_filter("PNG", &["png"])
+ .save_file()
+ .map(|p| p.to_string_lossy().to_string())
+ })),
+ }
+}
+
+const APP_ID: &str = "ai.storyteller.cadcraft";
+
+fn app_icon() -> Option {
+ let png: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-256.png");
+ eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
+}
+
+fn version_string() -> String {
+ let sha = option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev");
+ format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION"))
+}
+
+fn main() -> eframe::Result {
+ let mut control_port: Option = std::env::var("CADCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
+ let mut files = Vec::new();
+ let mut sample = false;
+ let mut args = std::env::args().skip(1);
+ while let Some(a) = args.next() {
+ match a.as_str() {
+ "--control" => control_port = args.next().and_then(|p| p.parse().ok()),
+ "--sample" => sample = true,
+ "--version" | "-V" => {
+ println!("{}", version_string());
+ return Ok(());
+ }
+ _ => files.push(a),
+ }
+ }
+ install_io();
+ let mut options = eframe::NativeOptions {
+ viewport: egui::ViewportBuilder::default()
+ .with_title("CADCraft")
+ .with_inner_size([1600.0, 1000.0])
+ .with_min_inner_size([900.0, 560.0])
+ .with_drag_and_drop(true)
+ .with_fullsize_content_view(true)
+ .with_titlebar_shown(false)
+ .with_title_shown(false)
+ .with_app_id(APP_ID),
+ ..Default::default()
+ };
+ if let Some(icon) = app_icon() {
+ options.viewport = options.viewport.with_icon(icon);
+ }
+ eframe::run_native(
+ "CADCraft",
+ options,
+ Box::new(move |cc| {
+ let mut app = CadApp::new(Session::empty(), services());
+ app.integrated_titlebar = cfg!(target_os = "macos");
+ if let Some(port) = control_port {
+ let rx = control_server::start(port, cc.egui_ctx.clone());
+ app = app.with_control(rx);
+ }
+ if sample {
+ let _ = app.run("ui.sample", serde_json::json!({}));
+ }
+ for f in &files {
+ app.open_path(f);
+ }
+ if app.session.docs.is_empty() {
+ app.session.new_drawing(false);
+ }
+ Ok(Box::new(App(
+ app,
+ #[cfg(target_os = "macos")]
+ None,
+ )))
+ }),
+ )
+}
diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs
new file mode 100644
index 00000000..dfb8c905
--- /dev/null
+++ b/apps/cadcraft/src/native_menu.rs
@@ -0,0 +1,98 @@
+//! macOS: CADCraft's menu tree as the native system menu bar. Items dispatch through the same
+//! command path as the in-window menus; enablement is refreshed a few times per second.
+
+use std::collections::HashMap;
+use std::str::FromStr;
+
+use cadcraft_ui_egui::CadApp;
+use cadcraft_ui_egui::menus::{self, Entry};
+use muda::accelerator::Accelerator;
+use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
+
+pub struct NativeMenu {
+ _menu: Menu,
+ items: HashMap,
+ last_refresh: f64,
+}
+
+/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
+fn accel(sc: &str) -> Option {
+ if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt")) {
+ return None;
+ }
+ Accelerator::from_str(&sc.replace("Cmd", "CMD").replace("Alt", "ALT").replace("Shift", "SHIFT").replace("Ctrl", "CTRL")).ok()
+}
+
+impl NativeMenu {
+ pub fn install(app: &mut CadApp) -> Self {
+ let menu = Menu::new();
+ let mut items = HashMap::new();
+ let mut counter = 0usize;
+ let app_menu = Submenu::new("CADCraft", true);
+ let about = MenuItem::with_id("cc-about", "About CADCraft", true, None);
+ let discord = MenuItem::with_id("cc-discord", "Join the ArtCraft Discord…", true, None);
+ let _ = app_menu.append_items(&[
+ &about,
+ &discord,
+ &PredefinedMenuItem::separator(),
+ &PredefinedMenuItem::services(None),
+ &PredefinedMenuItem::separator(),
+ &PredefinedMenuItem::hide(None),
+ &PredefinedMenuItem::hide_others(None),
+ &PredefinedMenuItem::show_all(None),
+ &PredefinedMenuItem::separator(),
+ &PredefinedMenuItem::quit(None),
+ ]);
+ items.insert("cc-about".into(), ("ui.dialog.about".into(), about));
+ items.insert("cc-discord".into(), ("ui.discord".into(), discord));
+ let _ = menu.append(&app_menu);
+ for (title, entries) in menus::tree(app) {
+ let sub = Submenu::new(&title, true);
+ build(&sub, &entries, &mut items, &mut counter);
+ let _ = menu.append(&sub);
+ }
+ menu.init_for_nsapp();
+ Self { _menu: menu, items, last_refresh: 0.0 }
+ }
+
+ pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
+ while let Ok(ev) = MenuEvent::receiver().try_recv() {
+ if let Some((cmd, _)) = self.items.get(ev.id.as_ref()) {
+ if cmd == "ui.discord" {
+ ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
+ } else {
+ menus::activate(app, &cmd.clone());
+ }
+ ctx.request_repaint();
+ }
+ }
+ let now = cadcraft_ui_egui::now_ms();
+ if now - self.last_refresh < 300.0 {
+ return;
+ }
+ self.last_refresh = now;
+ for (cmd, item) in self.items.values() {
+ let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
+ item.set_enabled(en);
+ }
+ }
+}
+
+fn build(parent: &Submenu, entries: &[Entry], items: &mut HashMap, counter: &mut usize) {
+ for e in entries {
+ match e {
+ Entry::Sub { label, children } => {
+ let sub = Submenu::new(label, true);
+ build(&sub, children, items, counter);
+ let _ = parent.append(&sub);
+ }
+ Entry::Item { label, id, shortcut, enabled } => {
+ *counter += 1;
+ let mid = format!("cc{counter}");
+ let i = MenuItem::with_id(mid.clone(), label, *enabled, shortcut.as_deref().and_then(accel));
+ let _ = parent.append(&i);
+ items.insert(mid, (id.clone(), i));
+ }
+ }
+ }
+}
diff --git a/assets/app-icon/cadcraft-1024.png b/assets/app-icon/cadcraft-1024.png
new file mode 100644
index 00000000..0b1ec121
Binary files /dev/null and b/assets/app-icon/cadcraft-1024.png differ
diff --git a/assets/app-icon/cadcraft-128.png b/assets/app-icon/cadcraft-128.png
new file mode 100644
index 00000000..ee4a016e
Binary files /dev/null and b/assets/app-icon/cadcraft-128.png differ
diff --git a/assets/app-icon/cadcraft-16.png b/assets/app-icon/cadcraft-16.png
new file mode 100644
index 00000000..22f81a45
Binary files /dev/null and b/assets/app-icon/cadcraft-16.png differ
diff --git a/assets/app-icon/cadcraft-256.png b/assets/app-icon/cadcraft-256.png
new file mode 100644
index 00000000..e341e025
Binary files /dev/null and b/assets/app-icon/cadcraft-256.png differ
diff --git a/assets/app-icon/cadcraft-32.png b/assets/app-icon/cadcraft-32.png
new file mode 100644
index 00000000..a3b76030
Binary files /dev/null and b/assets/app-icon/cadcraft-32.png differ
diff --git a/assets/app-icon/cadcraft-48.png b/assets/app-icon/cadcraft-48.png
new file mode 100644
index 00000000..41fab91e
Binary files /dev/null and b/assets/app-icon/cadcraft-48.png differ
diff --git a/assets/app-icon/cadcraft-512.png b/assets/app-icon/cadcraft-512.png
new file mode 100644
index 00000000..b580a0b0
Binary files /dev/null and b/assets/app-icon/cadcraft-512.png differ
diff --git a/assets/app-icon/cadcraft-64.png b/assets/app-icon/cadcraft-64.png
new file mode 100644
index 00000000..0547708f
Binary files /dev/null and b/assets/app-icon/cadcraft-64.png differ
diff --git a/assets/app-icon/cadcraft-macos-512.png b/assets/app-icon/cadcraft-macos-512.png
new file mode 100644
index 00000000..5661cf69
Binary files /dev/null and b/assets/app-icon/cadcraft-macos-512.png differ
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 00000000..4b6c79f5
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,4 @@
+allow-unwrap-in-tests = true
+allow-expect-in-tests = true
+allow-panic-in-tests = true
+allow-indexing-slicing-in-tests = true
diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml
new file mode 100644
index 00000000..cdad405a
--- /dev/null
+++ b/crates/color/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "cadcraft-color"
+description = "CADCraft colour: AutoCAD Color Index palette, true colour, ByLayer/ByBlock"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+serde = { workspace = true }
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
new file mode 100644
index 00000000..2975e693
--- /dev/null
+++ b/crates/color/src/lib.rs
@@ -0,0 +1,265 @@
+//! CAD colours: the 256-entry colour index (ACI), 24-bit true colour, and the logical
+//! ByLayer / ByBlock values. The index palette is generated from its well-known structure
+//! (24 hues × 5 values × 2 saturations, plus primaries and greys), not copied from any file.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+
+/// An 8-bit sRGB colour.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct Rgb(pub u8, pub u8, pub u8);
+
+impl Rgb {
+ pub fn to_u32(self) -> u32 {
+ (u32::from(self.0) << 16) | (u32::from(self.1) << 8) | u32::from(self.2)
+ }
+ pub fn from_u32(v: u32) -> Rgb {
+ Rgb(((v >> 16) & 0xff) as u8, ((v >> 8) & 0xff) as u8, (v & 0xff) as u8)
+ }
+ pub fn hex(self) -> String {
+ format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
+ }
+ pub fn parse_hex(s: &str) -> Option {
+ let s = s.trim().trim_start_matches('#');
+ if s.len() != 6 || !s.is_ascii() {
+ return None;
+ }
+ u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
+ }
+ /// Relative luminance (0..1) for contrast decisions.
+ pub fn luma(self) -> f64 {
+ (0.2126 * f64::from(self.0) + 0.7152 * f64::from(self.1) + 0.0722 * f64::from(self.2)) / 255.0
+ }
+}
+
+/// An entity or layer colour.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
+pub enum Color {
+ #[default]
+ ByLayer,
+ ByBlock,
+ /// ACI 1..=255.
+ Index(u8),
+ True(Rgb),
+}
+
+impl Color {
+ pub const WHITE: Color = Color::Index(7);
+ /// DXF group 62 value: 0 = ByBlock, 256 = ByLayer.
+ pub fn to_aci(self) -> i16 {
+ match self {
+ Color::ByLayer => 256,
+ Color::ByBlock => 0,
+ Color::Index(i) => i16::from(i),
+ Color::True(rgb) => i16::from(nearest_aci(rgb)),
+ }
+ }
+ pub fn from_aci(v: i16) -> Color {
+ match v {
+ 0 => Color::ByBlock,
+ 1..=255 => Color::Index(v as u8),
+ _ => Color::ByLayer,
+ }
+ }
+ /// Resolve to RGB given the layer and block colours.
+ pub fn resolve(self, layer: Color, block: Color) -> Rgb {
+ match self {
+ Color::ByLayer => match layer {
+ Color::ByLayer | Color::ByBlock => aci_rgb(7),
+ c => c.resolve(Color::Index(7), Color::Index(7)),
+ },
+ Color::ByBlock => match block {
+ Color::ByLayer | Color::ByBlock => aci_rgb(7),
+ c => c.resolve(Color::Index(7), Color::Index(7)),
+ },
+ Color::Index(i) => aci_rgb(i),
+ Color::True(c) => c,
+ }
+ }
+ /// Short display name like AutoCAD's Properties palette ("ByLayer", "Red", "Color 34", "255,128,0").
+ pub fn name(self) -> String {
+ match self {
+ Color::ByLayer => "ByLayer".into(),
+ Color::ByBlock => "ByBlock".into(),
+ Color::Index(i) => match i {
+ 1 => "Red".into(),
+ 2 => "Yellow".into(),
+ 3 => "Green".into(),
+ 4 => "Cyan".into(),
+ 5 => "Blue".into(),
+ 6 => "Magenta".into(),
+ 7 => "White".into(),
+ _ => format!("Color {i}"),
+ },
+ Color::True(Rgb(r, g, b)) => format!("{r},{g},{b}"),
+ }
+ }
+ /// Parse "red", "7", "bylayer", "255,0,0", "#ff0000".
+ pub fn parse(s: &str) -> Option {
+ let t = s.trim().to_ascii_lowercase();
+ let named = ["", "red", "yellow", "green", "cyan", "blue", "magenta", "white"];
+ if let Some(i) = named.iter().position(|n| !n.is_empty() && *n == t) {
+ return Some(Color::Index(i as u8));
+ }
+ match t.as_str() {
+ "bylayer" => return Some(Color::ByLayer),
+ "byblock" => return Some(Color::ByBlock),
+ "black" => return Some(Color::Index(7)),
+ _ => {}
+ }
+ if let Some(rest) = t.strip_prefix("color ") {
+ return rest.trim().parse::().ok().filter(|i| *i >= 1).map(Color::Index);
+ }
+ if let Ok(i) = t.parse::() {
+ return match i {
+ 0 => Some(Color::ByBlock),
+ 256 => Some(Color::ByLayer),
+ 1..=255 => Some(Color::Index(i as u8)),
+ _ => None,
+ };
+ }
+ if t.starts_with('#') {
+ return Rgb::parse_hex(&t).map(Color::True);
+ }
+ let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::()).collect();
+ if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
+ return Some(Color::True(Rgb(*r, *g, *b)));
+ }
+ None
+ }
+}
+
+/// The RGB of ACI `i` (0 and out-of-range map to white/7).
+pub fn aci_rgb(i: u8) -> Rgb {
+ PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
+}
+
+/// The palette as a 256-entry table (entry 0 = ByBlock placeholder = black).
+pub static PALETTE: std::sync::LazyLock<[Rgb; 256]> = std::sync::LazyLock::new(build_palette);
+
+fn build_palette() -> [Rgb; 256] {
+ let mut p = [Rgb(0, 0, 0); 256];
+ let base = [
+ Rgb(0, 0, 0),
+ Rgb(255, 0, 0),
+ Rgb(255, 255, 0),
+ Rgb(0, 255, 0),
+ Rgb(0, 255, 255),
+ Rgb(0, 0, 255),
+ Rgb(255, 0, 255),
+ Rgb(255, 255, 255),
+ Rgb(128, 128, 128),
+ Rgb(192, 192, 192),
+ ];
+ for (slot, c) in p.iter_mut().zip(base) {
+ *slot = c;
+ }
+ let values = [255.0, 165.0, 127.0, 76.0, 38.0];
+ for i in 10..250usize {
+ let hue = (i - 10) / 10; // 0..24, 15° each
+ let sub = i % 10;
+ let v: f64 = values.get(sub / 2).copied().unwrap_or(255.0);
+ let half = sub % 2 == 1;
+ let (r, g, b) = hue_fractions(hue);
+ let ch = |f: f64| -> u8 {
+ let f = if half { 0.5 + 0.5 * f } else { f };
+ (v * f).floor().clamp(0.0, 255.0) as u8
+ };
+ if let Some(slot) = p.get_mut(i) {
+ *slot = Rgb(ch(r), ch(g), ch(b));
+ }
+ }
+ let greys = [51u8, 91, 132, 173, 214, 255];
+ for (k, g) in greys.iter().enumerate() {
+ if let Some(slot) = p.get_mut(250 + k) {
+ *slot = Rgb(*g, *g, *g);
+ }
+ }
+ p
+}
+
+/// Channel fractions for hue step 0..24 (red → yellow → green → cyan → blue → magenta → red).
+fn hue_fractions(h: usize) -> (f64, f64, f64) {
+ let seg = h / 4;
+ let t = (h % 4) as f64 / 4.0;
+ match seg {
+ 0 => (1.0, t, 0.0),
+ 1 => (1.0 - t, 1.0, 0.0),
+ 2 => (0.0, 1.0, t),
+ 3 => (0.0, 1.0 - t, 1.0),
+ 4 => (t, 0.0, 1.0),
+ _ => (1.0, 0.0, 1.0 - t),
+ }
+}
+
+/// The closest index colour to an RGB value (1..=255).
+pub fn nearest_aci(c: Rgb) -> u8 {
+ let mut best = (u32::MAX, 7u8);
+ for i in 1..=255u8 {
+ let p = aci_rgb(i);
+ let d = |a: u8, b: u8| (i32::from(a) - i32::from(b)).pow(2) as u32;
+ let dist = d(p.0, c.0) + d(p.1, c.1) + d(p.2, c.2);
+ if dist < best.0 {
+ best = (dist, i);
+ }
+ }
+ best.1
+}
+
+/// Canvas display colour: ACI 7 (and any near-white/near-black) flips to contrast with the
+/// background, as CAD programs do on dark or light model space.
+pub fn display_rgb(c: Rgb, background: Rgb) -> Rgb {
+ let dark_bg = background.luma() < 0.5;
+ if dark_bg && c.luma() < 0.06 {
+ Rgb(255, 255, 255)
+ } else if !dark_bg && c.luma() > 0.94 {
+ Rgb(0, 0, 0)
+ } else {
+ c
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn palette_known_entries() {
+ assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
+ assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
+ assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
+ assert_eq!(aci_rgb(12), Rgb(165, 0, 0));
+ assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
+ assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
+ assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
+ assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
+ assert_eq!(aci_rgb(90), Rgb(0, 255, 0));
+ assert_eq!(aci_rgb(130), Rgb(0, 255, 255));
+ assert_eq!(aci_rgb(170), Rgb(0, 0, 255));
+ assert_eq!(aci_rgb(210), Rgb(255, 0, 255));
+ assert_eq!(aci_rgb(250), Rgb(51, 51, 51));
+ assert_eq!(aci_rgb(255), Rgb(255, 255, 255));
+ }
+
+ #[test]
+ fn aci_roundtrip_and_parse() {
+ assert_eq!(Color::from_aci(256), Color::ByLayer);
+ assert_eq!(Color::from_aci(0), Color::ByBlock);
+ assert_eq!(Color::Index(5).to_aci(), 5);
+ assert_eq!(Color::parse("Red"), Some(Color::Index(1)));
+ assert_eq!(Color::parse("bylayer"), Some(Color::ByLayer));
+ assert_eq!(Color::parse("255,128,0"), Some(Color::True(Rgb(255, 128, 0))));
+ assert_eq!(Color::parse("#00ff00"), Some(Color::True(Rgb(0, 255, 0))));
+ assert_eq!(Color::parse("300"), None);
+ assert_eq!(Color::parse("color 34"), Some(Color::Index(34)));
+ assert_eq!(nearest_aci(Rgb(250, 2, 3)), 1);
+ }
+
+ #[test]
+ fn white_flips_on_light_background() {
+ assert_eq!(display_rgb(Rgb(255, 255, 255), Rgb(255, 255, 255)), Rgb(0, 0, 0));
+ assert_eq!(display_rgb(Rgb(255, 255, 255), Rgb(33, 40, 48)), Rgb(255, 255, 255));
+ }
+}
diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml
new file mode 100644
index 00000000..7046fd40
--- /dev/null
+++ b/crates/doc/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "cadcraft-doc"
+description = "CADCraft drawing database: entities, symbol tables, blocks, layouts, copy-on-write store"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+cadcraft-geom = { workspace = true }
+cadcraft-color = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs
new file mode 100644
index 00000000..fe4c8336
--- /dev/null
+++ b/crates/doc/src/entity.rs
@@ -0,0 +1,897 @@
+//! Entities: the graphical objects of a drawing.
+
+use cadcraft_color::Color;
+use cadcraft_geom::{
+ Arc as GArc, Bounds2, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Mat3, PolyVertex, Polyline, Segment, Spline as GSpline, Vec2, Vec3,
+};
+use serde::{Deserialize, Serialize};
+
+/// A database handle (DXF group 5), unique per drawing.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct Handle(pub u64);
+
+impl Handle {
+ pub fn hex(self) -> String {
+ format!("{:X}", self.0)
+ }
+ pub fn parse_hex(s: &str) -> Option {
+ u64::from_str_radix(s.trim(), 16).ok().map(Handle)
+ }
+}
+
+/// Lineweight in hundredths of a millimetre, or a logical value.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Lineweight {
+ #[default]
+ ByLayer,
+ ByBlock,
+ Default,
+ /// 0..=211 (hundredths of mm).
+ Mm100(u16),
+}
+
+impl Lineweight {
+ /// The standard lineweight values (mm × 100).
+ pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
+ pub fn to_dxf(self) -> i16 {
+ match self {
+ Lineweight::ByLayer => -1,
+ Lineweight::ByBlock => -2,
+ Lineweight::Default => -3,
+ Lineweight::Mm100(v) => v.min(211) as i16,
+ }
+ }
+ pub fn from_dxf(v: i16) -> Lineweight {
+ match v {
+ -2 => Lineweight::ByBlock,
+ -3 => Lineweight::Default,
+ 0..=211 => Lineweight::Mm100(v as u16),
+ _ => Lineweight::ByLayer,
+ }
+ }
+ pub fn name(self) -> String {
+ match self {
+ Lineweight::ByLayer => "ByLayer".into(),
+ Lineweight::ByBlock => "ByBlock".into(),
+ Lineweight::Default => "Default".into(),
+ Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
+ }
+ }
+}
+
+/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Transparency {
+ #[default]
+ ByLayer,
+ ByBlock,
+ Percent(u8),
+}
+
+/// Properties every entity has.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct Common {
+ pub layer: String,
+ pub color: Color,
+ pub linetype: String,
+ pub lineweight: Lineweight,
+ pub ltscale: f64,
+ pub transparency: Transparency,
+ pub visible: bool,
+ pub thickness: f64,
+ pub extrusion: Vec3,
+}
+
+impl Default for Common {
+ fn default() -> Self {
+ Common {
+ layer: "0".into(),
+ color: Color::ByLayer,
+ linetype: "ByLayer".into(),
+ lineweight: Lineweight::ByLayer,
+ ltscale: 1.0,
+ transparency: Transparency::ByLayer,
+ visible: true,
+ thickness: 0.0,
+ extrusion: Vec3::Z,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Entity {
+ pub handle: Handle,
+ #[serde(default)]
+ pub common: Common,
+ pub kind: EntityKind,
+}
+
+impl Entity {
+ pub fn new(handle: Handle, kind: EntityKind) -> Self {
+ Entity { handle, common: Common::default(), kind }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Line {
+ pub a: Vec3,
+ pub b: Vec3,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Point {
+ pub p: Vec3,
+ #[serde(default)]
+ pub angle: f64,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Circle {
+ pub center: Vec3,
+ pub radius: f64,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Arc {
+ pub center: Vec3,
+ pub radius: f64,
+ /// Radians, CCW.
+ pub start: f64,
+ pub end: f64,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Ellipse {
+ pub center: Vec3,
+ pub major: Vec3,
+ pub ratio: f64,
+ pub start: f64,
+ pub end: f64,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct LwPolyline {
+ pub vertices: Vec,
+ pub closed: bool,
+ #[serde(default)]
+ pub const_width: f64,
+ #[serde(default)]
+ pub elevation: f64,
+ /// Polyline linetype generation (PLINEGEN).
+ #[serde(default)]
+ pub plinegen: bool,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Polyline3d {
+ pub points: Vec,
+ pub closed: bool,
+}
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum HAlign {
+ #[default]
+ Left,
+ Center,
+ Right,
+ Aligned,
+ Middle,
+ Fit,
+}
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum VAlign {
+ #[default]
+ Baseline,
+ Bottom,
+ Middle,
+ Top,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Text {
+ pub insert: Vec3,
+ #[serde(default)]
+ pub align_pt: Option,
+ pub height: f64,
+ pub value: String,
+ #[serde(default)]
+ pub rotation: f64,
+ #[serde(default = "one")]
+ pub width_factor: f64,
+ #[serde(default)]
+ pub oblique: f64,
+ #[serde(default = "standard")]
+ pub style: String,
+ #[serde(default)]
+ pub halign: HAlign,
+ #[serde(default)]
+ pub valign: VAlign,
+}
+fn one() -> f64 {
+ 1.0
+}
+fn standard() -> String {
+ "Standard".into()
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MText {
+ pub insert: Vec3,
+ pub height: f64,
+ /// Reference rectangle width (0 = no wrapping).
+ #[serde(default)]
+ pub width: f64,
+ /// 1..=9: TL TC TR ML MC MR BL BC BR.
+ #[serde(default = "attach_tl")]
+ pub attach: u8,
+ #[serde(default)]
+ pub rotation: f64,
+ #[serde(default = "standard")]
+ pub style: String,
+ /// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
+ pub contents: String,
+ #[serde(default = "one")]
+ pub line_spacing: f64,
+}
+fn attach_tl() -> u8 {
+ 1
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Attrib {
+ pub tag: String,
+ pub text: Text,
+ #[serde(default)]
+ pub invisible: bool,
+ #[serde(default)]
+ pub constant: bool,
+ #[serde(default)]
+ pub prompt: String,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Insert {
+ pub block: String,
+ pub insert: Vec3,
+ #[serde(default = "unit_scale")]
+ pub scale: Vec3,
+ #[serde(default)]
+ pub rotation: f64,
+ #[serde(default)]
+ pub attribs: Vec,
+ #[serde(default = "one_u")]
+ pub cols: u32,
+ #[serde(default = "one_u")]
+ pub rows: u32,
+ #[serde(default)]
+ pub col_spacing: f64,
+ #[serde(default)]
+ pub row_spacing: f64,
+}
+fn unit_scale() -> Vec3 {
+ Vec3::new(1.0, 1.0, 1.0)
+}
+fn one_u() -> u32 {
+ 1
+}
+impl Insert {
+ /// Block-to-world transform (2D part).
+ pub fn transform(&self, base: Vec2) -> Mat3 {
+ Mat3::translate(self.insert.xy())
+ .then_before(Mat3::rotate(self.rotation))
+ .then_before(Mat3::scale(self.scale.x, self.scale.y))
+ .then_before(Mat3::translate(-base))
+ }
+}
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum DimKind {
+ /// Rotated linear dimension (horizontal = 0, vertical = 90°).
+ Linear {
+ rotation: f64,
+ },
+ Aligned,
+ Angular,
+ Angular3P,
+ Diameter,
+ Radius,
+ Ordinate {
+ x_type: bool,
+ },
+ ArcLength,
+}
+/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
+/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Dimension {
+ pub kind: DimKind,
+ pub defpt: Vec3,
+ pub text_mid: Vec3,
+ #[serde(default)]
+ pub p13: Vec3,
+ #[serde(default)]
+ pub p14: Vec3,
+ #[serde(default)]
+ pub p15: Vec3,
+ #[serde(default)]
+ pub p16: Vec3,
+ /// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
+ #[serde(default)]
+ pub text: String,
+ #[serde(default = "standard")]
+ pub style: String,
+ #[serde(default)]
+ pub measurement: f64,
+ #[serde(default)]
+ pub text_rotation: f64,
+ /// User moved the text (text_mid is authoritative).
+ #[serde(default)]
+ pub user_text_pos: bool,
+ /// Anonymous block with the rendered geometry (from files); regenerated when edited.
+ #[serde(default)]
+ pub block: Option,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Leader {
+ pub vertices: Vec,
+ #[serde(default = "yes")]
+ pub arrow: bool,
+ #[serde(default)]
+ pub spline: bool,
+ #[serde(default = "standard")]
+ pub style: String,
+}
+fn yes() -> bool {
+ true
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MLeader {
+ pub leaders: Vec>,
+ pub landing: Vec3,
+ pub dogleg: f64,
+ pub text: Option,
+ #[serde(default = "standard")]
+ pub style: String,
+ #[serde(default = "one")]
+ pub arrow_size: f64,
+}
+/// A hatch boundary loop: closed polyline with bulges.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct HatchLoop {
+ pub vertices: Vec,
+ #[serde(default)]
+ pub outer: bool,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Hatch {
+ pub pattern: String,
+ pub solid: bool,
+ pub loops: Vec,
+ #[serde(default = "one")]
+ pub scale: f64,
+ #[serde(default)]
+ pub angle: f64,
+ #[serde(default)]
+ pub associative: bool,
+ /// 0 normal (odd parity), 1 outer, 2 ignore.
+ #[serde(default)]
+ pub style: u8,
+ #[serde(default)]
+ pub elevation: f64,
+ /// Gradient fill: name + second colour.
+ #[serde(default)]
+ pub gradient: Option,
+ #[serde(default)]
+ pub origin: Vec2,
+ #[serde(default)]
+ pub background: Option,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Gradient {
+ pub name: String,
+ pub color1: Color,
+ pub color2: Color,
+ #[serde(default)]
+ pub angle: f64,
+ #[serde(default)]
+ pub centered: bool,
+}
+/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Solid {
+ pub corners: [Vec3; 4],
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Face3d {
+ pub corners: [Vec3; 4],
+ #[serde(default)]
+ pub hidden_edges: u8,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct RayLine {
+ pub base: Vec3,
+ pub dir: Vec3,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Viewport {
+ pub center: Vec3,
+ pub width: f64,
+ pub height: f64,
+ pub view_center: Vec2,
+ pub view_height: f64,
+ #[serde(default)]
+ pub id: u32,
+ #[serde(default)]
+ pub locked: bool,
+ #[serde(default)]
+ pub frozen_layers: Vec,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Image {
+ pub insert: Vec3,
+ pub u: Vec3,
+ pub v: Vec3,
+ pub size: Vec2,
+ pub path: String,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Wipeout {
+ pub boundary: Vec,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TableCell {
+ pub text: String,
+ #[serde(default)]
+ pub merged: Option<(u32, u32)>,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Table {
+ pub insert: Vec3,
+ pub col_widths: Vec,
+ pub row_heights: Vec,
+ pub cells: Vec>,
+ #[serde(default = "standard")]
+ pub style: String,
+ #[serde(default = "default_text_h")]
+ pub text_height: f64,
+}
+fn default_text_h() -> f64 {
+ 0.18
+}
+/// A raw DXF group: kept for round-tripping entities we do not model.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct RawTag {
+ pub code: i32,
+ pub value: String,
+}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Unknown {
+ pub dxf_type: String,
+ pub tags: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum EntityKind {
+ Line(Line),
+ Point(Point),
+ Circle(Circle),
+ Arc(Arc),
+ Ellipse(Ellipse),
+ LwPolyline(LwPolyline),
+ Polyline3d(Polyline3d),
+ Spline(GSpline),
+ Ray(RayLine),
+ XLine(RayLine),
+ Text(Text),
+ MText(MText),
+ AttDef(Attrib),
+ Insert(Insert),
+ Dimension(Dimension),
+ Leader(Leader),
+ MLeader(MLeader),
+ Hatch(Hatch),
+ Solid(Solid),
+ Trace(Solid),
+ Face3d(Face3d),
+ Viewport(Viewport),
+ Image(Image),
+ Wipeout(Wipeout),
+ Table(Table),
+ Unknown(Unknown),
+}
+
+/// A primitive the entity decomposes into, for snapping, selection, trimming and rendering.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Prim {
+ Seg(Segment),
+ Circle(GCircle),
+ Ellipse(GEllipse),
+ Spline(GSpline),
+ Point(Vec2),
+ /// An infinite line (`ray == false`) or a ray from `base` along `dir`.
+ Infinite {
+ base: Vec2,
+ dir: Vec2,
+ ray: bool,
+ },
+ /// A filled polygon (solids, wide polylines, hatch fill) — the outline is also selectable.
+ Fill(Vec),
+}
+
+impl EntityKind {
+ /// AutoCAD's object type name as shown in the Properties palette.
+ pub fn type_name(&self) -> &'static str {
+ match self {
+ EntityKind::Line(_) => "Line",
+ EntityKind::Point(_) => "Point",
+ EntityKind::Circle(_) => "Circle",
+ EntityKind::Arc(_) => "Arc",
+ EntityKind::Ellipse(_) => "Ellipse",
+ EntityKind::LwPolyline(_) => "Polyline",
+ EntityKind::Polyline3d(_) => "3D Polyline",
+ EntityKind::Spline(_) => "Spline",
+ EntityKind::Ray(_) => "Ray",
+ EntityKind::XLine(_) => "Xline",
+ EntityKind::Text(_) => "Text",
+ EntityKind::MText(_) => "MText",
+ EntityKind::AttDef(_) => "Attribute Definition",
+ EntityKind::Insert(_) => "Block Reference",
+ EntityKind::Dimension(_) => "Dimension",
+ EntityKind::Leader(_) => "Leader",
+ EntityKind::MLeader(_) => "Multileader",
+ EntityKind::Hatch(_) => "Hatch",
+ EntityKind::Solid(_) => "2D Solid",
+ EntityKind::Trace(_) => "Trace",
+ EntityKind::Face3d(_) => "3D Face",
+ EntityKind::Viewport(_) => "Viewport",
+ EntityKind::Image(_) => "Raster Image",
+ EntityKind::Wipeout(_) => "Wipeout",
+ EntityKind::Table(_) => "Table",
+ EntityKind::Unknown(_) => "Proxy",
+ }
+ }
+ /// DXF entity type name.
+ pub fn dxf_name(&self) -> &str {
+ match self {
+ EntityKind::Line(_) => "LINE",
+ EntityKind::Point(_) => "POINT",
+ EntityKind::Circle(_) => "CIRCLE",
+ EntityKind::Arc(_) => "ARC",
+ EntityKind::Ellipse(_) => "ELLIPSE",
+ EntityKind::LwPolyline(_) => "LWPOLYLINE",
+ EntityKind::Polyline3d(_) => "POLYLINE",
+ EntityKind::Spline(_) => "SPLINE",
+ EntityKind::Ray(_) => "RAY",
+ EntityKind::XLine(_) => "XLINE",
+ EntityKind::Text(_) => "TEXT",
+ EntityKind::MText(_) => "MTEXT",
+ EntityKind::AttDef(_) => "ATTDEF",
+ EntityKind::Insert(_) => "INSERT",
+ EntityKind::Dimension(_) => "DIMENSION",
+ EntityKind::Leader(_) => "LEADER",
+ EntityKind::MLeader(_) => "MULTILEADER",
+ EntityKind::Hatch(_) => "HATCH",
+ EntityKind::Solid(_) => "SOLID",
+ EntityKind::Trace(_) => "TRACE",
+ EntityKind::Face3d(_) => "3DFACE",
+ EntityKind::Viewport(_) => "VIEWPORT",
+ EntityKind::Image(_) => "IMAGE",
+ EntityKind::Wipeout(_) => "WIPEOUT",
+ EntityKind::Table(_) => "ACAD_TABLE",
+ EntityKind::Unknown(u) => u.dxf_type.as_str(),
+ }
+ }
+
+ /// Geometric primitives in WCS (2D projection). Text/blocks/dimensions return their
+ /// insertion points only; their full geometry comes from the renderer's expansion.
+ pub fn prims(&self) -> Vec {
+ match self {
+ EntityKind::Line(l) => vec![Prim::Seg(Segment::Line(GLine::new(l.a.xy(), l.b.xy())))],
+ EntityKind::Point(p) => vec![Prim::Point(p.p.xy())],
+ EntityKind::Circle(c) => vec![Prim::Circle(GCircle::new(c.center.xy(), c.radius))],
+ EntityKind::Arc(a) => vec![Prim::Seg(Segment::Arc { arc: GArc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true })],
+ EntityKind::Ellipse(e) => {
+ vec![Prim::Ellipse(GEllipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end })]
+ }
+ EntityKind::LwPolyline(p) => {
+ let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
+ pl.segments().into_iter().map(Prim::Seg).collect()
+ }
+ EntityKind::Polyline3d(p) => {
+ let mut v: Vec =
+ p.points.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(w.first()?.xy(), w.get(1)?.xy()))))).collect();
+ if p.closed
+ && let (Some(a), Some(b)) = (p.points.last(), p.points.first())
+ {
+ v.push(Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy()))));
+ }
+ v
+ }
+ EntityKind::Spline(s) => vec![Prim::Spline(s.clone())],
+ EntityKind::Ray(r) => vec![Prim::Infinite { base: r.base.xy(), dir: r.dir.xy().normalized(), ray: true }],
+ EntityKind::XLine(r) => vec![Prim::Infinite { base: r.base.xy(), dir: r.dir.xy().normalized(), ray: false }],
+ EntityKind::Solid(s) | EntityKind::Trace(s) => {
+ let c = &s.corners;
+ vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])]
+ }
+ EntityKind::Face3d(f) => {
+ let c = &f.corners;
+ let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
+ pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
+ }
+ EntityKind::Hatch(h) => {
+ h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect()
+ }
+ EntityKind::Wipeout(w) => {
+ let mut pts = w.boundary.clone();
+ if let Some(f) = pts.first().copied() {
+ pts.push(f);
+ }
+ pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
+ }
+ EntityKind::Viewport(v) => {
+ let c = v.center.xy();
+ let h = Vec2::new(v.width / 2.0, v.height / 2.0);
+ let b = Bounds2::new(c - h, c + h).corners();
+ (0..4).map(|i| Prim::Seg(Segment::Line(GLine::new(b[i], b[(i + 1) % 4])))).collect()
+ }
+ EntityKind::Leader(l) => {
+ l.vertices.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(w.first()?.xy(), w.get(1)?.xy()))))).collect()
+ }
+ EntityKind::Text(t) => vec![Prim::Point(t.insert.xy())],
+ EntityKind::MText(t) => vec![Prim::Point(t.insert.xy())],
+ EntityKind::AttDef(a) => vec![Prim::Point(a.text.insert.xy())],
+ EntityKind::Insert(i) => vec![Prim::Point(i.insert.xy())],
+ EntityKind::Dimension(d) => vec![Prim::Point(d.defpt.xy())],
+ EntityKind::MLeader(m) => m
+ .leaders
+ .iter()
+ .flat_map(|l| l.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(w.first()?.xy(), w.get(1)?.xy()))))))
+ .collect(),
+ EntityKind::Image(i) => vec![Prim::Point(i.insert.xy())],
+ EntityKind::Table(t) => vec![Prim::Point(t.insert.xy())],
+ EntityKind::Unknown(_) => Vec::new(),
+ }
+ }
+
+ /// Apply a 2D affine transform (move/rotate/uniform scale/mirror). Non-uniform scale turns
+ /// circles into ellipses only where the kind supports it; otherwise radii use the mean scale.
+ pub fn transform(&mut self, m: &Mat3) {
+ let t3 = |p: &mut Vec3| {
+ let q = m.apply(p.xy());
+ p.x = q.x;
+ p.y = q.y;
+ };
+ let s = m.scale_factor();
+ let rot = m.rotation();
+ let mirror = m.is_mirroring();
+ match self {
+ EntityKind::Line(l) => {
+ t3(&mut l.a);
+ t3(&mut l.b);
+ }
+ EntityKind::Point(p) => t3(&mut p.p),
+ EntityKind::Circle(c) => {
+ t3(&mut c.center);
+ c.radius *= s;
+ }
+ EntityKind::Arc(a) => {
+ let sp = m.apply(Vec2::polar(a.center.xy(), a.radius, a.start));
+ let ep = m.apply(Vec2::polar(a.center.xy(), a.radius, a.end));
+ t3(&mut a.center);
+ a.radius *= s;
+ let c = a.center.xy();
+ if mirror {
+ a.start = c.angle_to(ep);
+ a.end = c.angle_to(sp);
+ } else {
+ a.start = c.angle_to(sp);
+ a.end = c.angle_to(ep);
+ }
+ }
+ EntityKind::Ellipse(e) => {
+ let sp = m.apply(e.center.xy() + e.major.xy() * e.start.cos() + e.major.xy().perp() * (e.ratio * e.start.sin()));
+ let ep = m.apply(e.center.xy() + e.major.xy() * e.end.cos() + e.major.xy().perp() * (e.ratio * e.end.sin()));
+ t3(&mut e.center);
+ let mj = m.apply_vec(e.major.xy());
+ e.major = mj.to3(0.0);
+ let full = (cadcraft_geom::ccw_sweep(e.start, e.end) - cadcraft_geom::TAU).abs() < 1e-9;
+ if !full {
+ let ge = GEllipse { center: e.center.xy(), major: mj, ratio: e.ratio, start: 0.0, end: cadcraft_geom::TAU };
+ let (ps, pe) = (ge.param_of(sp), ge.param_of(ep));
+ if mirror {
+ e.start = pe;
+ e.end = ps;
+ } else {
+ e.start = ps;
+ e.end = pe;
+ }
+ }
+ }
+ EntityKind::LwPolyline(p) => {
+ for v in &mut p.vertices {
+ v.p = m.apply(v.p);
+ v.start_width *= s;
+ v.end_width *= s;
+ if mirror {
+ v.bulge = -v.bulge;
+ }
+ }
+ p.const_width *= s;
+ }
+ EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(t3),
+ EntityKind::Spline(sp) => {
+ for c in sp.control.iter_mut().chain(sp.fit.iter_mut()) {
+ *c = m.apply(*c);
+ }
+ }
+ EntityKind::Ray(r) | EntityKind::XLine(r) => {
+ t3(&mut r.base);
+ r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
+ }
+ EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
+ EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
+ EntityKind::MText(t) => {
+ t3(&mut t.insert);
+ t.height *= s;
+ t.width *= s;
+ t.rotation = cadcraft_geom::norm_angle(t.rotation + rot);
+ }
+ EntityKind::Insert(i) => {
+ t3(&mut i.insert);
+ i.scale.x *= s;
+ i.scale.y *= s;
+ i.scale.z *= s;
+ i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
+ if mirror {
+ i.scale.y = -i.scale.y;
+ i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
+ }
+ for a in &mut i.attribs {
+ transform_text(&mut a.text, m, s, rot, false);
+ }
+ i.col_spacing *= s;
+ i.row_spacing *= s;
+ }
+ 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] {
+ t3(p);
+ }
+ if let DimKind::Linear { rotation } = &mut d.kind {
+ *rotation = cadcraft_geom::norm_angle(*rotation + rot);
+ }
+ d.block = None;
+ }
+ EntityKind::Leader(l) => l.vertices.iter_mut().for_each(t3),
+ EntityKind::MLeader(ml) => {
+ for l in &mut ml.leaders {
+ l.iter_mut().for_each(t3);
+ }
+ t3(&mut ml.landing);
+ if let Some(t) = &mut ml.text {
+ t3(&mut t.insert);
+ t.height *= s;
+ }
+ }
+ EntityKind::Hatch(h) => {
+ for l in &mut h.loops {
+ for v in &mut l.vertices {
+ v.p = m.apply(v.p);
+ if mirror {
+ v.bulge = -v.bulge;
+ }
+ }
+ }
+ h.scale *= s;
+ h.angle = cadcraft_geom::norm_angle(h.angle + rot);
+ h.origin = m.apply(h.origin);
+ }
+ EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
+ EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
+ EntityKind::Viewport(v) => {
+ t3(&mut v.center);
+ v.width *= s;
+ v.height *= s;
+ }
+ EntityKind::Image(i) => {
+ t3(&mut i.insert);
+ i.u = m.apply_vec(i.u.xy()).to3(0.0);
+ i.v = m.apply_vec(i.v.xy()).to3(0.0);
+ }
+ EntityKind::Wipeout(w) => w.boundary.iter_mut().for_each(|p| *p = m.apply(*p)),
+ EntityKind::Table(t) => {
+ t3(&mut t.insert);
+ t.col_widths.iter_mut().for_each(|w| *w *= s);
+ t.row_heights.iter_mut().for_each(|h| *h *= s);
+ t.text_height *= s;
+ }
+ EntityKind::Unknown(_) => {}
+ }
+ }
+
+ /// Grip points (editable defining points), in display order.
+ pub fn grips(&self) -> Vec {
+ match self {
+ EntityKind::Line(l) => vec![l.a.xy(), l.a.xy().mid(l.b.xy()), l.b.xy()],
+ EntityKind::Point(p) => vec![p.p.xy()],
+ EntityKind::Circle(c) => {
+ let o = c.center.xy();
+ vec![o, o + Vec2::X * c.radius, o + Vec2::Y * c.radius, o - Vec2::X * c.radius, o - Vec2::Y * c.radius]
+ }
+ EntityKind::Arc(a) => {
+ let g = GArc::new(a.center.xy(), a.radius, a.start, a.end);
+ vec![g.start_point(), g.mid_point(), g.end_point(), a.center.xy()]
+ }
+ EntityKind::Ellipse(e) => {
+ let c = e.center.xy();
+ let mj = e.major.xy();
+ let mn = mj.perp() * e.ratio;
+ vec![c, c + mj, c + mn, c - mj, c - mn]
+ }
+ EntityKind::LwPolyline(p) => {
+ let mut g: Vec = Vec::new();
+ let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
+ for (i, v) in p.vertices.iter().enumerate() {
+ g.push(v.p);
+ if let Some(s) = pl.segments().get(i) {
+ g.push(s.mid());
+ }
+ }
+ g
+ }
+ EntityKind::Polyline3d(p) => p.points.iter().map(|q| q.xy()).collect(),
+ EntityKind::Spline(s) => {
+ if s.fit.is_empty() {
+ s.control.clone()
+ } else {
+ s.fit.clone()
+ }
+ }
+ EntityKind::Ray(r) | EntityKind::XLine(r) => vec![r.base.xy(), r.base.xy() + r.dir.xy()],
+ EntityKind::Text(t) => vec![t.insert.xy()],
+ EntityKind::MText(t) => vec![t.insert.xy()],
+ EntityKind::AttDef(a) => vec![a.text.insert.xy()],
+ EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.insert.xy())).collect(),
+ EntityKind::Dimension(d) => vec![d.p13.xy(), d.p14.xy(), d.defpt.xy(), d.text_mid.xy()],
+ EntityKind::Leader(l) => l.vertices.iter().map(|v| v.xy()).collect(),
+ EntityKind::MLeader(m) => m.leaders.iter().flat_map(|l| l.iter().map(|v| v.xy())).chain(std::iter::once(m.landing.xy())).collect(),
+ EntityKind::Hatch(h) => {
+ let b = Bounds2::from_points(h.loops.iter().flat_map(|l| l.vertices.iter().map(|v| v.p)));
+ vec![b.center()]
+ }
+ EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
+ EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
+ EntityKind::Viewport(v) => {
+ let c = v.center.xy();
+ let h = Vec2::new(v.width / 2.0, v.height / 2.0);
+ Bounds2::new(c - h, c + h).corners().to_vec()
+ }
+ EntityKind::Image(i) => vec![i.insert.xy()],
+ EntityKind::Wipeout(w) => w.boundary.clone(),
+ EntityKind::Table(t) => vec![t.insert.xy()],
+ EntityKind::Unknown(_) => Vec::new(),
+ }
+ }
+}
+
+fn transform_text(t: &mut Text, m: &Mat3, s: f64, rot: f64, mirror: bool) {
+ let q = m.apply(t.insert.xy());
+ t.insert.x = q.x;
+ t.insert.y = q.y;
+ if let Some(a) = &mut t.align_pt {
+ let q = m.apply(a.xy());
+ a.x = q.x;
+ a.y = q.y;
+ }
+ t.height *= s;
+ // MIRRTEXT = 0 (the default): mirrored text keeps reading left to right.
+ t.rotation = cadcraft_geom::norm_angle(t.rotation + if mirror { 0.0 } else { rot });
+}
diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs
new file mode 100644
index 00000000..cdc6b30e
--- /dev/null
+++ b/crates/doc/src/extents.rs
@@ -0,0 +1,126 @@
+//! Approximate entity bounds (used for extents, zoom and selection pre-filtering).
+
+use cadcraft_geom::{Bounds2, Polyline, Vec2};
+
+use crate::{Drawing, Entity, EntityKind, Prim};
+
+/// Nested block references deeper than this are ignored (cyclic or hostile files).
+pub const MAX_BLOCK_DEPTH: usize = 16;
+
+fn text_box(insert: Vec2, height: f64, len: usize, width_factor: f64, rotation: f64) -> Bounds2 {
+ let w = height * 0.8 * width_factor.abs().max(0.01) * len.max(1) as f64;
+ let pts = [Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, height), Vec2::new(0.0, height)].map(|p| insert + p.rotate(rotation));
+ Bounds2::from_points(pts)
+}
+
+pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
+ match &e.kind {
+ EntityKind::Text(t) => text_box(
+ t.align_pt.filter(|_| t.halign != crate::HAlign::Left).unwrap_or(t.insert).xy(),
+ t.height,
+ t.value.chars().count(),
+ t.width_factor,
+ t.rotation,
+ ),
+ EntityKind::AttDef(a) => text_box(a.text.insert.xy(), a.text.height, a.tag.chars().count(), a.text.width_factor, a.text.rotation),
+ EntityKind::MText(t) => {
+ let lines = t.contents.split("\\P").count().max(1);
+ let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
+ let w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 };
+ let h = t.height * 1.66 * lines as f64;
+ let (dx, dy) = match t.attach {
+ 2 | 5 | 8 => (-w / 2.0, 0.0),
+ 3 | 6 | 9 => (-w, 0.0),
+ _ => (0.0, 0.0),
+ };
+ let top = match t.attach {
+ 4..=6 => h / 2.0,
+ 7..=9 => h,
+ _ => 0.0,
+ };
+ let o = t.insert.xy();
+ let pts = [Vec2::new(dx, top + dy), Vec2::new(dx + w, top + dy), Vec2::new(dx + w, top - h + dy), Vec2::new(dx, top - h + dy)]
+ .map(|p| o + p.rotate(t.rotation));
+ Bounds2::from_points(pts)
+ }
+ EntityKind::Insert(ins) => {
+ if depth >= MAX_BLOCK_DEPTH {
+ return Bounds2::EMPTY;
+ }
+ let Some(blk) = d.block(&ins.block) else { return Bounds2::from_points([ins.insert.xy()]) };
+ let m = ins.transform(blk.base.xy());
+ let mut inner = Bounds2::EMPTY;
+ for be in blk.entities.iter() {
+ inner = inner.union(&entity_bounds(d, be, depth + 1));
+ }
+ let mut b =
+ if inner.is_empty() { Bounds2::from_points([ins.insert.xy()]) } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
+ if ins.cols > 1 || ins.rows > 1 {
+ let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
+ .rotate(ins.rotation);
+ b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
+ }
+ for a in &ins.attribs {
+ if !a.invisible {
+ b = b.union(&text_box(a.text.insert.xy(), a.text.height, a.text.value.chars().count(), a.text.width_factor, a.text.rotation));
+ }
+ }
+ b
+ }
+ EntityKind::Dimension(dm) => {
+ let mut b = Bounds2::from_points(
+ [dm.defpt.xy(), dm.text_mid.xy(), dm.p13.xy(), dm.p14.xy()].into_iter().filter(|p| *p != Vec2::ZERO || dm.defpt.xy() == Vec2::ZERO),
+ );
+ if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
+ && depth < MAX_BLOCK_DEPTH
+ {
+ for be in blk.entities.iter() {
+ b = b.union(&entity_bounds(d, be, depth + 1));
+ }
+ }
+ b
+ }
+ EntityKind::MLeader(m) => {
+ let mut b = Bounds2::from_points(m.leaders.iter().flatten().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())));
+ if let Some(t) = &m.text {
+ b.add(t.insert.xy());
+ }
+ b
+ }
+ EntityKind::Table(t) => {
+ let w: f64 = t.col_widths.iter().sum();
+ let h: f64 = t.row_heights.iter().sum();
+ Bounds2::new(t.insert.xy(), t.insert.xy() + Vec2::new(w, -h))
+ }
+ EntityKind::Image(i) => {
+ let o = i.insert.xy();
+ let u = i.u.xy() * i.size.x;
+ let v = i.v.xy() * i.size.y;
+ Bounds2::from_points([o, o + u, o + v, o + u + v])
+ }
+ EntityKind::LwPolyline(p) => Polyline { vertices: p.vertices.clone(), closed: p.closed }.bounds().expand(p.const_width / 2.0),
+ kind => {
+ let mut b = Bounds2::EMPTY;
+ for p in kind.prims() {
+ let pb = match p {
+ Prim::Seg(s) => s.bounds(),
+ Prim::Circle(c) => c.bounds(),
+ Prim::Ellipse(e) => e.bounds(),
+ Prim::Spline(s) => s.bounds(),
+ Prim::Point(p) => Bounds2::from_points([p]),
+ // Infinite lines don't contribute to extents (as in ZOOM Extents).
+ Prim::Infinite { base, ray, .. } => {
+ if ray {
+ Bounds2::from_points([base])
+ } else {
+ Bounds2::EMPTY
+ }
+ }
+ Prim::Fill(pts) => Bounds2::from_points(pts),
+ };
+ b = b.union(&pb);
+ }
+ b
+ }
+ }
+}
diff --git a/crates/doc/src/header.rs b/crates/doc/src/header.rs
new file mode 100644
index 00000000..fb4702f7
--- /dev/null
+++ b/crates/doc/src/header.rs
@@ -0,0 +1,146 @@
+//! Header (drawing-scoped system) variables.
+
+use std::collections::BTreeMap;
+
+use cadcraft_geom::Vec3;
+use serde::{Deserialize, Serialize};
+
+/// A header variable value.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum HVal {
+ Int(i64),
+ Real(f64),
+ Str(String),
+ Point(Vec3),
+}
+
+impl HVal {
+ pub fn as_f64(&self) -> Option {
+ match self {
+ HVal::Int(i) => Some(*i as f64),
+ HVal::Real(r) => Some(*r),
+ HVal::Str(s) => s.trim().parse().ok(),
+ HVal::Point(_) => None,
+ }
+ }
+ pub fn as_i64(&self) -> Option {
+ match self {
+ HVal::Int(i) => Some(*i),
+ HVal::Real(r) if r.is_finite() => Some(r.round() as i64),
+ HVal::Str(s) => s.trim().parse().ok(),
+ _ => None,
+ }
+ }
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ HVal::Str(s) => Some(s),
+ _ => None,
+ }
+ }
+ pub fn display(&self) -> String {
+ match self {
+ HVal::Int(i) => i.to_string(),
+ HVal::Real(r) => format!("{r:.4}"),
+ HVal::Str(s) => format!("\"{s}\""),
+ HVal::Point(p) => format!("{:.4},{:.4},{:.4}", p.x, p.y, p.z),
+ }
+ }
+}
+
+/// Header variables keyed by name without the `$` (e.g. `LTSCALE`), uppercase.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct Header {
+ pub vars: BTreeMap,
+}
+
+impl Header {
+ pub fn get(&self, name: &str) -> Option<&HVal> {
+ self.vars.get(&name.trim_start_matches('$').to_ascii_uppercase())
+ }
+ pub fn set(&mut self, name: &str, v: HVal) {
+ self.vars.insert(name.trim_start_matches('$').to_ascii_uppercase(), v);
+ }
+ pub fn f64(&self, name: &str, default: f64) -> f64 {
+ self.get(name).and_then(HVal::as_f64).filter(|v| v.is_finite()).unwrap_or(default)
+ }
+ pub fn i64(&self, name: &str, default: i64) -> i64 {
+ self.get(name).and_then(HVal::as_i64).unwrap_or(default)
+ }
+ pub fn str(&self, name: &str, default: &str) -> String {
+ self.get(name).and_then(HVal::as_str).map(str::to_string).unwrap_or_else(|| default.to_string())
+ }
+ pub fn point(&self, name: &str) -> Option {
+ match self.get(name)? {
+ HVal::Point(p) => Some(*p),
+ _ => None,
+ }
+ }
+ pub fn set_f64(&mut self, name: &str, v: f64) {
+ self.set(name, HVal::Real(v));
+ }
+ pub fn set_i64(&mut self, name: &str, v: i64) {
+ self.set(name, HVal::Int(v));
+ }
+ pub fn set_str(&mut self, name: &str, v: &str) {
+ self.set(name, HVal::Str(v.into()));
+ }
+
+ fn common() -> Header {
+ let mut h = Header::default();
+ h.set_str("ACADVER", "AC1032");
+ h.set_str("CLAYER", "0");
+ h.set_i64("CECOLOR", 256);
+ h.set_str("CELTYPE", "ByLayer");
+ h.set_i64("CELWEIGHT", -1);
+ h.set_f64("CELTSCALE", 1.0);
+ h.set_f64("LTSCALE", 1.0);
+ h.set_i64("PSLTSCALE", 1);
+ h.set_str("TEXTSTYLE", "Standard");
+ h.set_str("DIMSTYLE", "Standard");
+ h.set_str("CMLEADERSTYLE", "Standard");
+ h.set_str("CTABLESTYLE", "Standard");
+ h.set_i64("LUNITS", 2);
+ h.set_i64("LUPREC", 4);
+ h.set_i64("AUNITS", 0);
+ h.set_i64("AUPREC", 0);
+ h.set_f64("ANGBASE", 0.0);
+ h.set_i64("ANGDIR", 0);
+ h.set_i64("PDMODE", 0);
+ h.set_f64("PDSIZE", 0.0);
+ h.set_i64("FILLMODE", 1);
+ h.set_i64("MIRRTEXT", 0);
+ h.set_i64("ATTMODE", 1);
+ h.set_f64("THICKNESS", 0.0);
+ h.set_f64("ELEVATION", 0.0);
+ h.set_i64("LWDISPLAY", 0);
+ h.set("INSBASE", HVal::Point(Vec3::ZERO));
+ h
+ }
+
+ pub fn imperial() -> Header {
+ let mut h = Header::common();
+ h.set_i64("MEASUREMENT", 0);
+ h.set_i64("INSUNITS", 1);
+ h.set_f64("TEXTSIZE", 0.2);
+ h.set_f64("DIMSCALE", 1.0);
+ h.set_f64("FILLETRAD", 0.0);
+ h.set_f64("CHAMFERA", 0.0);
+ h.set_f64("CHAMFERB", 0.0);
+ h.set("LIMMIN", HVal::Point(Vec3::ZERO));
+ h.set("LIMMAX", HVal::Point(Vec3::new(12.0, 9.0, 0.0)));
+ h
+ }
+
+ pub fn metric() -> Header {
+ let mut h = Header::common();
+ h.set_i64("MEASUREMENT", 1);
+ h.set_i64("INSUNITS", 4);
+ h.set_f64("TEXTSIZE", 2.5);
+ h.set_f64("DIMSCALE", 1.0);
+ h.set_f64("FILLETRAD", 0.0);
+ h.set("LIMMIN", HVal::Point(Vec3::ZERO));
+ h.set("LIMMAX", HVal::Point(Vec3::new(420.0, 297.0, 0.0)));
+ h
+ }
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
new file mode 100644
index 00000000..c458e8b7
--- /dev/null
+++ b/crates/doc/src/lib.rs
@@ -0,0 +1,237 @@
+//! The CADCraft drawing database.
+//!
+//! A [`Drawing`] holds the header variables, symbol tables (layers, linetypes, styles), block
+//! definitions, model space and paper-space layouts. Entity collections are copy-on-write
+//! ([`EntityStore`]), so a whole-drawing clone is cheap and serves as an undo snapshot.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#![forbid(unsafe_code)]
+
+mod entity;
+mod extents;
+mod header;
+pub mod library;
+mod store;
+mod tables;
+
+use std::collections::BTreeMap;
+
+pub use cadcraft_color as color;
+pub use cadcraft_geom as geom;
+pub use entity::*;
+pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
+pub use header::{HVal, Header};
+pub use store::EntityStore;
+pub use tables::*;
+
+#[derive(Debug, thiserror::Error)]
+pub enum DocError {
+ #[error("no such layer `{0}`")]
+ NoLayer(String),
+ #[error("no such block `{0}`")]
+ NoBlock(String),
+ #[error("no such entity {0:?}")]
+ NoEntity(Handle),
+ #[error("{0}")]
+ Invalid(String),
+}
+
+pub type Result = std::result::Result;
+
+/// Which entity collection: model space or a named layout's paper space.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Space {
+ #[default]
+ Model,
+ Paper(String),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Drawing {
+ pub header: Header,
+ pub layers: Vec,
+ pub linetypes: Vec,
+ pub text_styles: Vec,
+ pub dim_styles: Vec,
+ pub mleader_styles: Vec,
+ pub table_styles: Vec,
+ pub blocks: BTreeMap>,
+ pub model: EntityStore,
+ pub layouts: Vec,
+ pub views: Vec,
+ pub ucss: Vec,
+ pub layer_states: Vec,
+ pub groups: Vec,
+ /// Next free handle.
+ pub handseed: u64,
+}
+
+impl Default for Drawing {
+ fn default() -> Self {
+ Drawing::new_imperial()
+ }
+}
+
+impl Drawing {
+ /// An empty drawing with the standard tables (inch-based, like acad.dwt-style defaults).
+ pub fn new_imperial() -> Self {
+ let mut d = Drawing::bare();
+ d.header = Header::imperial();
+ d
+ }
+ /// An empty metric drawing (millimetres, ISO-25 dimensions).
+ pub fn new_metric() -> Self {
+ let mut d = Drawing::bare();
+ d.header = Header::metric();
+ d.dim_styles.push(DimStyle::iso25());
+ d.header.set_str("DIMSTYLE", "ISO-25");
+ d
+ }
+ fn bare() -> Self {
+ Drawing {
+ header: Header::default(),
+ layers: vec![Layer::default(), Layer { name: "Defpoints".into(), plot: false, ..Layer::default() }],
+ linetypes: vec![
+ Linetype { name: "ByBlock".into(), description: String::new(), pattern: Vec::new() },
+ Linetype { name: "ByLayer".into(), description: String::new(), pattern: Vec::new() },
+ Linetype::continuous(),
+ ],
+ text_styles: vec![TextStyle::default()],
+ dim_styles: vec![DimStyle::default()],
+ mleader_styles: vec![MLeaderStyle::default()],
+ table_styles: vec![TableStyle::default()],
+ blocks: BTreeMap::new(),
+ model: EntityStore::new(),
+ layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
+ views: Vec::new(),
+ ucss: Vec::new(),
+ layer_states: Vec::new(),
+ groups: Vec::new(),
+ handseed: 0x100,
+ }
+ }
+
+ /// Allocate a fresh handle.
+ pub fn new_handle(&mut self) -> Handle {
+ let h = Handle(self.handseed);
+ self.handseed = self.handseed.saturating_add(1);
+ h
+ }
+ /// Make sure future handles don't collide with `h` (after loading a file).
+ pub fn bump_handseed(&mut self, h: Handle) {
+ if h.0 >= self.handseed {
+ self.handseed = h.0.saturating_add(1);
+ }
+ }
+
+ pub fn layer(&self, name: &str) -> Option<&Layer> {
+ self.layers.iter().find(|l| l.name.eq_ignore_ascii_case(name))
+ }
+ pub fn layer_mut(&mut self, name: &str) -> Option<&mut Layer> {
+ self.layers.iter_mut().find(|l| l.name.eq_ignore_ascii_case(name))
+ }
+ pub fn linetype(&self, name: &str) -> Option<&Linetype> {
+ self.linetypes.iter().find(|l| l.name.eq_ignore_ascii_case(name))
+ }
+ pub fn text_style(&self, name: &str) -> Option<&TextStyle> {
+ self.text_styles.iter().find(|l| l.name.eq_ignore_ascii_case(name))
+ }
+ pub fn dim_style(&self, name: &str) -> Option<&DimStyle> {
+ self.dim_styles.iter().find(|l| l.name.eq_ignore_ascii_case(name))
+ }
+ pub fn block(&self, name: &str) -> Option<&std::sync::Arc> {
+ self.blocks.get(name).or_else(|| self.blocks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))
+ }
+ pub fn layout(&self, name: &str) -> Option<&Layout> {
+ self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name))
+ }
+
+ /// Add a layer if missing; returns whether it was created.
+ pub fn ensure_layer(&mut self, name: &str) -> bool {
+ if self.layer(name).is_some() {
+ return false;
+ }
+ self.layers.push(Layer::new(name));
+ true
+ }
+
+ pub fn space(&self, space: &Space) -> Option<&EntityStore> {
+ match space {
+ Space::Model => Some(&self.model),
+ Space::Paper(n) => self.layouts.iter().find(|l| &l.name == n).map(|l| &l.entities),
+ }
+ }
+ pub fn space_mut(&mut self, space: &Space) -> Option<&mut EntityStore> {
+ match space {
+ Space::Model => Some(&mut self.model),
+ Space::Paper(n) => self.layouts.iter_mut().find(|l| &l.name == n).map(|l| &mut l.entities),
+ }
+ }
+
+ /// Find an entity in model space or any layout.
+ pub fn entity(&self, h: Handle) -> Option<&std::sync::Arc> {
+ self.model.get(h).or_else(|| self.layouts.iter().find_map(|l| l.entities.get(h)))
+ }
+ pub fn space_of(&self, h: Handle) -> Option {
+ if self.model.contains(h) {
+ return Some(Space::Model);
+ }
+ self.layouts.iter().find(|l| l.entities.contains(h)).map(|l| Space::Paper(l.name.clone()))
+ }
+ pub fn modify_entity(&mut self, h: Handle, f: F) -> Result<()> {
+ let sp = self.space_of(h).ok_or(DocError::NoEntity(h))?;
+ let store = self.space_mut(&sp).ok_or(DocError::NoEntity(h))?;
+ if store.modify(h, f) { Ok(()) } else { Err(DocError::NoEntity(h)) }
+ }
+ pub fn remove_entity(&mut self, h: Handle) -> Option> {
+ let sp = self.space_of(h)?;
+ self.space_mut(&sp)?.remove(h)
+ }
+
+ /// Add an entity to a space with a new handle, using the given common properties.
+ pub fn add(&mut self, space: &Space, common: Common, kind: EntityKind) -> Result {
+ let h = self.new_handle();
+ self.ensure_layer(&common.layer);
+ let store = self.space_mut(space).ok_or_else(|| DocError::Invalid(format!("no such space {space:?}")))?;
+ store.push(Entity { handle: h, common, kind });
+ Ok(h)
+ }
+
+ /// Effective visibility of an entity (its own flag, its layer on/thawed).
+ pub fn is_visible(&self, e: &Entity) -> bool {
+ e.common.visible && self.layer(&e.common.layer).is_none_or(Layer::visible)
+ }
+
+ /// Bounds of all visible entities in a space.
+ pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
+ let mut b = cadcraft_geom::Bounds2::EMPTY;
+ if let Some(store) = self.space(space) {
+ for e in store.iter() {
+ if self.is_visible(e) {
+ b = b.union(&entity_bounds(self, e, 0));
+ }
+ }
+ }
+ b
+ }
+
+ /// Total entity count across model and paper space.
+ pub fn entity_count(&self) -> usize {
+ self.model.len() + self.layouts.iter().map(|l| l.entities.len()).sum::()
+ }
+
+ /// Next unused anonymous block name with the given prefix (e.g. `*D`).
+ pub fn anonymous_block_name(&self, prefix: &str) -> String {
+ let mut i = 1usize;
+ loop {
+ let n = format!("{prefix}{i}");
+ if self.block(&n).is_none() || i > 1_000_000 {
+ return n;
+ }
+ i += 1;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs
new file mode 100644
index 00000000..8781051a
--- /dev/null
+++ b/crates/doc/src/library.rs
@@ -0,0 +1,195 @@
+//! CADCraft's own standard libraries: linetypes and hatch patterns.
+//!
+//! These definitions were written for CADCraft (CC0 / public domain as part of this codebase).
+//! The names follow the industry-common names users expect (DASHED, CENTER, ANSI31…); the
+//! dash and spacing values are our own.
+
+use crate::{DashElement, Linetype};
+
+/// The standard linetype library (inch-based lengths; metric drawings scale with LTSCALE).
+pub fn standard_linetypes() -> Vec {
+ let s = |name: &str, desc: &str, d: &[f64]| Linetype::simple(name, desc, d);
+ vec![
+ s("BORDER", "Border __ __ . __ __ . __ __ .", &[0.5, -0.25, 0.5, -0.25, 0.0, -0.25]),
+ s("CENTER", "Center ____ _ ____ _ ____", &[1.25, -0.25, 0.25, -0.25]),
+ s("CENTER2", "Center (.5x) ___ _ ___ _", &[0.75, -0.125, 0.125, -0.125]),
+ s("CENTERX2", "Center (2x) ________ __ ________", &[2.5, -0.5, 0.5, -0.5]),
+ s("DASHDOT", "Dash dot __ . __ . __ .", &[0.5, -0.25, 0.0, -0.25]),
+ s("DASHDOT2", "Dash dot (.5x) _._._._.", &[0.25, -0.125, 0.0, -0.125]),
+ s("DASHED", "Dashed __ __ __ __", &[0.5, -0.25]),
+ s("DASHED2", "Dashed (.5x) _ _ _ _ _", &[0.25, -0.125]),
+ s("DASHEDX2", "Dashed (2x) ____ ____ ____", &[1.0, -0.5]),
+ s("DIVIDE", "Divide ____ . . ____ . .", &[0.5, -0.25, 0.0, -0.25, 0.0, -0.25]),
+ s("DOT", "Dot . . . . . . . .", &[0.0, -0.25]),
+ s("DOT2", "Dot (.5x) ........", &[0.0, -0.125]),
+ s("HIDDEN", "Hidden __ __ __ __", &[0.25, -0.125]),
+ s("HIDDEN2", "Hidden (.5x) _ _ _ _ _", &[0.125, -0.0625]),
+ s("HIDDENX2", "Hidden (2x) ____ ____ ____", &[0.5, -0.25]),
+ s("PHANTOM", "Phantom ______ __ __ ______", &[1.25, -0.25, 0.25, -0.25, 0.25, -0.25]),
+ s("PHANTOM2", "Phantom (.5x) ___ _ _ ___", &[0.625, -0.125, 0.125, -0.125, 0.125, -0.125]),
+ Linetype {
+ name: "FENCELINE1".into(),
+ description: "Fenceline circle ----0-----0----".into(),
+ pattern: vec![
+ DashElement::dash(0.25),
+ DashElement::dash(-0.1),
+ DashElement { text: Some("o".into()), scale: 0.1, ..DashElement::dash(-0.1) },
+ DashElement::dash(0.5),
+ ],
+ },
+ s("ZIGZAG", "Zig zag /\\/\\/\\/\\/", &[0.0001, -0.2, 0.0001, -0.2]),
+ ]
+}
+
+/// A hatch pattern line family: angle (degrees), origin, offset (shift along, spacing), dashes.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PatternLine {
+ pub angle: f64,
+ pub origin: (f64, f64),
+ pub delta: (f64, f64),
+ pub dashes: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct HatchPattern {
+ pub name: &'static str,
+ pub description: &'static str,
+ pub lines: Vec,
+}
+
+fn pl(angle: f64, origin: (f64, f64), delta: (f64, f64), dashes: &[f64]) -> PatternLine {
+ PatternLine { angle, origin, delta, dashes: dashes.to_vec() }
+}
+
+/// The standard hatch pattern library.
+pub fn standard_patterns() -> Vec {
+ vec![
+ HatchPattern { name: "SOLID", description: "Solid fill", lines: vec![] },
+ HatchPattern { name: "ANSI31", description: "Iron, brick, stone masonry", lines: vec![pl(45.0, (0.0, 0.0), (0.0, 0.125), &[])] },
+ HatchPattern {
+ name: "ANSI32",
+ description: "Steel",
+ lines: vec![pl(45.0, (0.0, 0.0), (0.0, 0.375), &[]), pl(45.0, (0.176777, 0.0), (0.0, 0.375), &[])],
+ },
+ HatchPattern {
+ name: "ANSI33",
+ description: "Bronze, brass, copper",
+ lines: vec![pl(45.0, (0.0, 0.0), (0.0, 0.25), &[]), pl(45.0, (0.176777, 0.0), (0.0, 0.25), &[0.125, -0.0625])],
+ },
+ HatchPattern {
+ name: "ANSI34",
+ description: "Plastic, rubber",
+ lines: (0..4).map(|i| pl(45.0, (0.176777 * f64::from(i) / 2.0, 0.0), (0.0, 0.75), &[])).collect(),
+ },
+ HatchPattern {
+ name: "ANSI35",
+ description: "Fire brick, refractory",
+ lines: vec![pl(45.0, (0.0, 0.0), (0.0, 0.25), &[]), pl(45.0, (0.176777, 0.0), (0.0, 0.25), &[0.3125, -0.0625, 0.0, -0.0625])],
+ },
+ HatchPattern {
+ name: "ANSI36",
+ description: "Marble, slate, glass",
+ lines: vec![pl(45.0, (0.0, 0.0), (0.21875, 0.125), &[0.3125, -0.0625, 0.0, -0.0625])],
+ },
+ HatchPattern {
+ name: "ANSI37",
+ description: "Lead, zinc, magnesium",
+ lines: vec![pl(45.0, (0.0, 0.0), (0.0, 0.125), &[]), pl(135.0, (0.0, 0.0), (0.0, 0.125), &[])],
+ },
+ HatchPattern {
+ name: "ANSI38",
+ description: "Aluminum",
+ lines: vec![pl(45.0, (0.0, 0.0), (0.0, 0.125), &[]), pl(135.0, (0.0, 0.0), (0.25, 0.125), &[0.3125, -0.1875])],
+ },
+ HatchPattern { name: "LINE", description: "Parallel horizontal lines", lines: vec![pl(0.0, (0.0, 0.0), (0.0, 0.125), &[])] },
+ HatchPattern {
+ name: "NET",
+ description: "Horizontal / vertical grid",
+ lines: vec![pl(0.0, (0.0, 0.0), (0.0, 0.125), &[]), pl(90.0, (0.0, 0.0), (0.0, 0.125), &[])],
+ },
+ HatchPattern {
+ name: "NET3",
+ description: "Network pattern 0-60-120",
+ lines: vec![pl(0.0, (0.0, 0.0), (0.0, 0.125), &[]), pl(60.0, (0.0, 0.0), (0.0, 0.125), &[]), pl(120.0, (0.0, 0.0), (0.0, 0.125), &[])],
+ },
+ HatchPattern { name: "DOTS", description: "A series of dots", lines: vec![pl(0.0, (0.0, 0.0), (0.03125, 0.0625), &[0.0, -0.0625])] },
+ HatchPattern { name: "DASH", description: "Dashed lines", lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125])] },
+ HatchPattern {
+ name: "BRICK",
+ description: "Brick or masonry-type surface",
+ lines: vec![
+ pl(0.0, (0.0, 0.0), (0.0, 0.25), &[]),
+ pl(90.0, (0.0, 0.0), (0.25, 0.25), &[0.25, -0.25]),
+ pl(90.0, (0.25, 0.0), (0.25, 0.25), &[-0.25, 0.25]),
+ ],
+ },
+ HatchPattern {
+ name: "SQUARE",
+ description: "Small aligned squares",
+ lines: vec![pl(0.0, (0.0, 0.0), (0.0, 0.125), &[0.125, -0.125]), pl(90.0, (0.0, 0.0), (0.0, 0.125), &[0.125, -0.125])],
+ },
+ HatchPattern {
+ name: "CROSS",
+ description: "A series of crosses",
+ lines: vec![pl(0.0, (0.0, 0.0), (0.25, 0.25), &[0.125, -0.375]), pl(90.0, (0.0625, -0.0625), (0.25, 0.25), &[0.125, -0.375])],
+ },
+ HatchPattern {
+ name: "EARTH",
+ description: "Earth or ground (subterranean)",
+ lines: vec![
+ pl(0.0, (0.0, 0.0), (0.25, 0.25), &[0.25, -0.25]),
+ pl(0.0, (0.0, 0.09375), (0.25, 0.25), &[0.25, -0.25]),
+ pl(0.0, (0.0, 0.1875), (0.25, 0.25), &[0.25, -0.25]),
+ pl(90.0, (0.03125, 0.21875), (0.25, 0.25), &[0.25, -0.25]),
+ pl(90.0, (0.125, 0.21875), (0.25, 0.25), &[0.25, -0.25]),
+ pl(90.0, (0.21875, 0.21875), (0.25, 0.25), &[0.25, -0.25]),
+ ],
+ },
+ HatchPattern {
+ name: "GRASS",
+ description: "Grass area",
+ lines: vec![
+ pl(90.0, (0.0, 0.0), (std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2), &[0.1875, -1.226]),
+ pl(45.0, (0.0, 0.0), (0.0, 1.0), &[0.1875, -0.8125]),
+ pl(135.0, (0.0, 0.0), (0.0, 1.0), &[0.1875, -0.8125]),
+ ],
+ },
+ HatchPattern {
+ name: "HONEY",
+ description: "Honeycomb pattern",
+ lines: vec![
+ pl(0.0, (0.0, 0.0), (0.1875, 0.108253), &[0.125, -0.25]),
+ pl(120.0, (0.0, 0.0), (0.1875, 0.108253), &[0.125, -0.25]),
+ pl(60.0, (0.125, 0.0), (0.1875, 0.108253), &[0.125, -0.25]),
+ ],
+ },
+ HatchPattern {
+ name: "ZIGZAG",
+ description: "Staircase effect",
+ lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125]), pl(90.0, (0.125, 0.0), (0.125, 0.125), &[0.125, -0.125])],
+ },
+ HatchPattern {
+ name: "AR-CONC",
+ description: "Random dot and stone pattern",
+ lines: vec![
+ pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
+ pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
+ pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
+ pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
+ ],
+ },
+ HatchPattern {
+ name: "AR-SAND",
+ description: "Random dot pattern",
+ lines: vec![
+ pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
+ pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
+ pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
+ ],
+ },
+ ]
+}
+
+pub fn pattern(name: &str) -> Option {
+ standard_patterns().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
+}
diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs
new file mode 100644
index 00000000..ad7ddef0
--- /dev/null
+++ b/crates/doc/src/store.rs
@@ -0,0 +1,248 @@
+//! A copy-on-write entity store with draw order.
+//!
+//! Entities live in chunks (`Arc>>`) in draw order, and a sharded handle index
+//! maps each handle to the stable id of its chunk. Cloning a store (an undo snapshot) copies
+//! only the chunk and shard pointers; an edit copies one chunk and one shard.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use crate::{Entity, Handle};
+
+const CHUNK_MAX: usize = 1024;
+const SHARDS: usize = 64;
+
+#[derive(Clone, Debug, Default)]
+struct Chunk {
+ id: u32,
+ items: Vec>,
+}
+
+#[derive(Clone, Debug)]
+pub struct EntityStore {
+ chunks: Vec>,
+ shards: Vec>>,
+ len: usize,
+ next_chunk: u32,
+}
+
+impl Default for EntityStore {
+ fn default() -> Self {
+ EntityStore { chunks: Vec::new(), shards: (0..SHARDS).map(|_| Arc::new(HashMap::new())).collect(), len: 0, next_chunk: 0 }
+ }
+}
+
+impl PartialEq for EntityStore {
+ fn eq(&self, other: &Self) -> bool {
+ self.len == other.len && self.iter().zip(other.iter()).all(|(a, b)| a == b)
+ }
+}
+
+fn shard_of(h: Handle) -> usize {
+ // Fibonacci hashing spreads sequential handles across shards.
+ ((h.0.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 58) as usize) % SHARDS
+}
+
+impl EntityStore {
+ pub fn new() -> Self {
+ Self::default()
+ }
+ pub fn len(&self) -> usize {
+ self.len
+ }
+ pub fn is_empty(&self) -> bool {
+ self.len == 0
+ }
+ /// Iterate in draw order.
+ pub fn iter(&self) -> impl Iterator- > + '_ {
+ self.chunks.iter().flat_map(|c| c.items.iter())
+ }
+ pub fn handles(&self) -> Vec
{
+ self.iter().map(|e| e.handle).collect()
+ }
+ pub fn contains(&self, h: Handle) -> bool {
+ self.chunk_id(h).is_some()
+ }
+ fn chunk_id(&self, h: Handle) -> Option {
+ self.shards.get(shard_of(h)).and_then(|s| s.get(&h).copied())
+ }
+ fn chunk_pos(&self, id: u32) -> Option {
+ self.chunks.iter().position(|c| c.id == id)
+ }
+ pub fn get(&self, h: Handle) -> Option<&Arc> {
+ let pos = self.chunk_pos(self.chunk_id(h)?)?;
+ self.chunks.get(pos)?.items.iter().find(|e| e.handle == h)
+ }
+ fn set_index(&mut self, h: Handle, chunk: Option) {
+ if let Some(shard) = self.shards.get_mut(shard_of(h)) {
+ let m = Arc::make_mut(shard);
+ match chunk {
+ Some(c) => {
+ m.insert(h, c);
+ }
+ None => {
+ m.remove(&h);
+ }
+ }
+ }
+ }
+ fn new_chunk(&mut self) -> Arc {
+ let id = self.next_chunk;
+ self.next_chunk = self.next_chunk.wrapping_add(1);
+ Arc::new(Chunk { id, items: Vec::with_capacity(64) })
+ }
+ /// Append on top of the draw order. Replaces an entity with the same handle in place.
+ pub fn push(&mut self, e: Entity) {
+ if self.contains(e.handle) {
+ self.replace(e);
+ return;
+ }
+ let needs_new = self.chunks.last().is_none_or(|c| c.items.len() >= CHUNK_MAX);
+ if needs_new {
+ let c = self.new_chunk();
+ self.chunks.push(c);
+ }
+ let h = e.handle;
+ let Some(last) = self.chunks.last_mut() else { return };
+ let chunk = Arc::make_mut(last);
+ chunk.items.push(Arc::new(e));
+ let id = chunk.id;
+ self.set_index(h, Some(id));
+ self.len += 1;
+ }
+ /// Insert at the bottom of the draw order.
+ pub fn push_front(&mut self, e: Entity) {
+ if self.contains(e.handle) {
+ self.replace(e);
+ return;
+ }
+ let needs_new = self.chunks.first().is_none_or(|c| c.items.len() >= CHUNK_MAX);
+ if needs_new {
+ let c = self.new_chunk();
+ self.chunks.insert(0, c);
+ }
+ let h = e.handle;
+ let Some(first) = self.chunks.first_mut() else { return };
+ let chunk = Arc::make_mut(first);
+ chunk.items.insert(0, Arc::new(e));
+ let id = chunk.id;
+ self.set_index(h, Some(id));
+ self.len += 1;
+ }
+ /// Replace the entity with the same handle, keeping its draw position. Returns false if absent.
+ pub fn replace(&mut self, e: Entity) -> bool {
+ let Some(id) = self.chunk_id(e.handle) else { return false };
+ let Some(pos) = self.chunk_pos(id) else { return false };
+ let Some(c) = self.chunks.get_mut(pos) else { return false };
+ let chunk = Arc::make_mut(c);
+ if let Some(slot) = chunk.items.iter_mut().find(|x| x.handle == e.handle) {
+ *slot = Arc::new(e);
+ true
+ } else {
+ false
+ }
+ }
+ pub fn remove(&mut self, h: Handle) -> Option> {
+ let id = self.chunk_id(h)?;
+ let pos = self.chunk_pos(id)?;
+ let c = self.chunks.get_mut(pos)?;
+ let chunk = Arc::make_mut(c);
+ let i = chunk.items.iter().position(|x| x.handle == h)?;
+ let e = chunk.items.remove(i);
+ let empty = chunk.items.is_empty();
+ if empty {
+ self.chunks.remove(pos);
+ }
+ self.set_index(h, None);
+ self.len = self.len.saturating_sub(1);
+ Some(e)
+ }
+ /// Modify an entity in place through a closure (copy-on-write).
+ pub fn modify(&mut self, h: Handle, f: F) -> bool {
+ let Some(id) = self.chunk_id(h) else { return false };
+ let Some(pos) = self.chunk_pos(id) else { return false };
+ let Some(c) = self.chunks.get_mut(pos) else { return false };
+ let chunk = Arc::make_mut(c);
+ match chunk.items.iter_mut().find(|x| x.handle == h) {
+ Some(slot) => {
+ f(Arc::make_mut(slot));
+ true
+ }
+ None => false,
+ }
+ }
+ pub fn bring_to_front(&mut self, h: Handle) -> bool {
+ match self.remove(h) {
+ Some(e) => {
+ self.push(Arc::unwrap_or_clone(e));
+ true
+ }
+ None => false,
+ }
+ }
+ pub fn send_to_back(&mut self, h: Handle) -> bool {
+ match self.remove(h) {
+ Some(e) => {
+ self.push_front(Arc::unwrap_or_clone(e));
+ true
+ }
+ None => false,
+ }
+ }
+ /// 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())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{EntityKind, Point};
+ use cadcraft_geom::Vec2;
+
+ fn ent(h: u64) -> Entity {
+ Entity::new(Handle(h), EntityKind::Point(Point { p: Vec2::new(h as f64, 0.0).to3(0.0), angle: 0.0 }))
+ }
+
+ #[test]
+ fn push_get_remove() {
+ let mut s = EntityStore::new();
+ for h in 1..=3000 {
+ s.push(ent(h));
+ }
+ assert_eq!(s.len(), 3000);
+ assert!(s.get(Handle(1500)).is_some());
+ assert!(s.remove(Handle(1500)).is_some());
+ assert!(s.get(Handle(1500)).is_none());
+ assert_eq!(s.len(), 2999);
+ assert_eq!(s.iter().count(), 2999);
+ }
+
+ #[test]
+ fn snapshots_are_independent() {
+ let mut s = EntityStore::new();
+ for h in 1..=10 {
+ s.push(ent(h));
+ }
+ let snap = s.clone();
+ s.remove(Handle(3));
+ s.modify(Handle(4), |e| e.common.layer = "X".into());
+ assert!(snap.get(Handle(3)).is_some());
+ assert_eq!(snap.get(Handle(4)).map(|e| e.common.layer.as_str()), Some("0"));
+ assert_eq!(s.get(Handle(4)).map(|e| e.common.layer.as_str()), Some("X"));
+ }
+
+ #[test]
+ fn draw_order_moves() {
+ let mut s = EntityStore::new();
+ for h in 1..=5 {
+ s.push(ent(h));
+ }
+ s.bring_to_front(Handle(1));
+ assert_eq!(s.last().map(|e| e.handle), Some(Handle(1)));
+ s.send_to_back(Handle(5));
+ assert_eq!(s.iter().next().map(|e| e.handle), Some(Handle(5)));
+ assert_eq!(s.len(), 5);
+ }
+}
diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs
new file mode 100644
index 00000000..e3c8be2b
--- /dev/null
+++ b/crates/doc/src/tables.rs
@@ -0,0 +1,412 @@
+//! Symbol tables and non-graphical objects: layers, linetypes, text and dimension styles,
+//! blocks, layouts, named views and UCSs.
+
+use std::sync::Arc;
+
+use cadcraft_color::Color;
+use cadcraft_geom::{Vec2, Vec3};
+use serde::{Deserialize, Serialize};
+
+use crate::{EntityStore, Lineweight};
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct Layer {
+ pub name: String,
+ /// Always an index or true colour (never ByLayer/ByBlock).
+ pub color: Color,
+ pub linetype: String,
+ pub lineweight: Lineweight,
+ pub on: bool,
+ pub frozen: bool,
+ pub locked: bool,
+ pub plot: bool,
+ /// 0..=90 percent.
+ pub transparency: u8,
+ pub description: String,
+ pub plot_style: String,
+}
+
+impl Default for Layer {
+ fn default() -> Self {
+ Layer {
+ name: "0".into(),
+ color: Color::Index(7),
+ linetype: "Continuous".into(),
+ lineweight: Lineweight::Default,
+ on: true,
+ frozen: false,
+ locked: false,
+ plot: true,
+ transparency: 0,
+ description: String::new(),
+ plot_style: "Normal".into(),
+ }
+ }
+}
+
+impl Layer {
+ pub fn new(name: &str) -> Self {
+ Layer { name: name.into(), ..Default::default() }
+ }
+ pub fn visible(&self) -> bool {
+ self.on && !self.frozen
+ }
+}
+
+/// A linetype element: dash (> 0), gap (< 0) or dot (0); optional embedded text/shape.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DashElement {
+ pub length: f64,
+ #[serde(default)]
+ pub text: Option,
+ #[serde(default)]
+ pub shape: Option,
+ #[serde(default)]
+ pub style: Option,
+ #[serde(default)]
+ pub scale: f64,
+ #[serde(default)]
+ pub rotation: f64,
+ #[serde(default)]
+ pub offset: Vec2,
+}
+
+impl DashElement {
+ pub fn dash(length: f64) -> Self {
+ DashElement { length, text: None, shape: None, style: None, scale: 1.0, rotation: 0.0, offset: Vec2::ZERO }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Linetype {
+ pub name: String,
+ pub description: String,
+ pub pattern: Vec,
+}
+
+impl Linetype {
+ pub fn continuous() -> Self {
+ Linetype { name: "Continuous".into(), description: "Solid line".into(), pattern: Vec::new() }
+ }
+ pub fn simple(name: &str, description: &str, dashes: &[f64]) -> Self {
+ Linetype { name: name.into(), description: description.into(), pattern: dashes.iter().map(|d| DashElement::dash(*d)).collect() }
+ }
+ pub fn pattern_length(&self) -> f64 {
+ self.pattern.iter().map(|d| d.length.abs()).sum()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct TextStyle {
+ pub name: String,
+ /// Font file or family name (TTF family or a stroke-font name).
+ pub font: String,
+ pub big_font: String,
+ /// 0 = not fixed.
+ pub height: f64,
+ pub width_factor: f64,
+ pub oblique: f64,
+ pub backwards: bool,
+ pub upside_down: bool,
+ pub vertical: bool,
+ pub annotative: bool,
+}
+
+impl Default for TextStyle {
+ fn default() -> Self {
+ TextStyle {
+ name: "Standard".into(),
+ font: "CADCraft Stroke".into(),
+ big_font: String::new(),
+ height: 0.0,
+ width_factor: 1.0,
+ oblique: 0.0,
+ backwards: false,
+ upside_down: false,
+ vertical: false,
+ annotative: false,
+ }
+ }
+}
+
+/// Dimension style variables (the subset that affects geometry and text).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct DimStyle {
+ pub name: String,
+ /// DIMSCALE overall scale.
+ pub scale: f64,
+ /// DIMASZ arrow size.
+ pub arrow_size: f64,
+ /// DIMEXO extension line offset from origin.
+ pub ext_offset: f64,
+ /// DIMEXE extension beyond dimension line.
+ pub ext_extend: f64,
+ /// DIMTXT text height.
+ pub text_height: f64,
+ /// DIMGAP gap around text.
+ pub text_gap: f64,
+ /// DIMDEC decimal places.
+ pub decimals: u8,
+ /// DIMADEC angular decimals.
+ pub angular_decimals: u8,
+ /// DIMLFAC linear scale factor.
+ pub linear_factor: f64,
+ /// DIMTAD text above line (0 centred, 1 above).
+ pub text_above: u8,
+ /// DIMTIH / DIMTOH text inside/outside horizontal.
+ pub text_inside_horizontal: bool,
+ pub text_outside_horizontal: bool,
+ /// DIMBLK arrowhead name ("" = closed filled).
+ pub arrow_block: String,
+ /// DIMTSZ tick size (> 0 replaces arrows with ticks).
+ pub tick_size: f64,
+ /// DIMCLRD / DIMCLRE / DIMCLRT.
+ pub dim_line_color: Color,
+ pub ext_line_color: Color,
+ pub text_color: Color,
+ pub text_style: String,
+ /// DIMPOST prefix/suffix "<>" template.
+ pub post: String,
+ /// DIMCEN centre mark size (negative = lines).
+ pub center_mark: f64,
+ /// DIMZIN zero suppression bits.
+ pub zero_suppression: u8,
+ /// DIMLUNIT: 1 sci, 2 dec, 3 eng, 4 arch, 5 frac, 6 windows.
+ pub linear_unit: u8,
+ /// DIMTOL / DIMLIM, DIMTP/DIMTM.
+ pub tolerance: bool,
+ pub tol_plus: f64,
+ pub tol_minus: f64,
+ /// DIMDLI baseline spacing.
+ pub baseline_spacing: f64,
+ pub annotative: bool,
+}
+
+impl Default for DimStyle {
+ fn default() -> Self {
+ DimStyle {
+ name: "Standard".into(),
+ scale: 1.0,
+ arrow_size: 0.18,
+ ext_offset: 0.0625,
+ ext_extend: 0.18,
+ text_height: 0.18,
+ text_gap: 0.09,
+ decimals: 4,
+ angular_decimals: 0,
+ linear_factor: 1.0,
+ text_above: 0,
+ text_inside_horizontal: true,
+ text_outside_horizontal: true,
+ arrow_block: String::new(),
+ tick_size: 0.0,
+ dim_line_color: Color::ByBlock,
+ ext_line_color: Color::ByBlock,
+ text_color: Color::ByBlock,
+ text_style: "Standard".into(),
+ post: String::new(),
+ center_mark: 0.09,
+ zero_suppression: 0,
+ linear_unit: 2,
+ tolerance: false,
+ tol_plus: 0.0,
+ tol_minus: 0.0,
+ baseline_spacing: 0.38,
+ annotative: false,
+ }
+ }
+}
+
+impl DimStyle {
+ /// The ISO-25 style for metric drawings.
+ pub fn iso25() -> Self {
+ DimStyle {
+ name: "ISO-25".into(),
+ arrow_size: 2.5,
+ ext_offset: 0.625,
+ ext_extend: 1.25,
+ text_height: 2.5,
+ text_gap: 0.625,
+ decimals: 2,
+ text_above: 1,
+ text_inside_horizontal: false,
+ text_outside_horizontal: false,
+ center_mark: 2.5,
+ zero_suppression: 8,
+ baseline_spacing: 3.75,
+ ..Default::default()
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct MLeaderStyle {
+ pub name: String,
+ pub arrow_size: f64,
+ pub text_height: f64,
+ pub landing_gap: f64,
+ pub dogleg: f64,
+ pub text_style: String,
+}
+
+impl Default for MLeaderStyle {
+ fn default() -> Self {
+ MLeaderStyle { name: "Standard".into(), arrow_size: 0.18, text_height: 0.18, landing_gap: 0.09, dogleg: 0.36, text_style: "Standard".into() }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct TableStyle {
+ pub name: String,
+ pub text_height: f64,
+ pub margin: f64,
+ pub title: bool,
+ pub header: bool,
+}
+
+impl Default for TableStyle {
+ fn default() -> Self {
+ TableStyle { name: "Standard".into(), text_height: 0.18, margin: 0.06, title: true, header: true }
+ }
+}
+
+/// A block definition.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Block {
+ pub name: String,
+ pub base: Vec3,
+ #[serde(skip)]
+ pub entities: EntityStore,
+ #[serde(default)]
+ pub description: String,
+ /// Anonymous (`*D12`, `*U3`…) blocks are generated geometry.
+ #[serde(default)]
+ pub anonymous: bool,
+ #[serde(default)]
+ pub xref_path: Option,
+ #[serde(default)]
+ pub explodable: bool,
+ #[serde(default)]
+ pub units: u8,
+}
+
+impl Block {
+ pub fn new(name: &str) -> Self {
+ Block {
+ name: name.into(),
+ base: Vec3::ZERO,
+ entities: EntityStore::new(),
+ description: String::new(),
+ anonymous: name.starts_with('*'),
+ xref_path: None,
+ explodable: true,
+ units: 0,
+ }
+ }
+}
+
+/// Paper size and plot settings for a layout (page setup).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct PageSetup {
+ pub device: String,
+ pub paper: String,
+ /// Paper size in millimetres (portrait width/height before rotation).
+ pub width_mm: f64,
+ pub height_mm: f64,
+ pub margins_mm: [f64; 4],
+ pub landscape: bool,
+ /// "layout" | "extents" | "display" | "window" | "limits".
+ pub plot_area: String,
+ pub scale_to_fit: bool,
+ /// Paper units per drawing unit.
+ pub scale: f64,
+ pub plot_style_table: String,
+ pub center: bool,
+ pub lineweights: bool,
+}
+
+impl Default for PageSetup {
+ fn default() -> Self {
+ PageSetup {
+ device: "None".into(),
+ paper: "ANSI A (8.50 x 11.00 Inches)".into(),
+ width_mm: 215.9,
+ height_mm: 279.4,
+ margins_mm: [6.35, 6.35, 6.35, 6.35],
+ landscape: true,
+ plot_area: "layout".into(),
+ scale_to_fit: false,
+ scale: 1.0,
+ plot_style_table: String::new(),
+ center: false,
+ lineweights: true,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Layout {
+ pub name: String,
+ pub tab_order: u32,
+ pub page: PageSetup,
+ #[serde(skip)]
+ pub entities: EntityStore,
+ /// Saved paper-space view (centre, height).
+ #[serde(default)]
+ pub view: Option<(Vec2, f64)>,
+}
+
+impl Layout {
+ pub fn new(name: &str, tab_order: u32) -> Self {
+ Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NamedView {
+ pub name: String,
+ pub center: Vec2,
+ pub height: f64,
+ pub width: f64,
+ #[serde(default)]
+ pub layer_state: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Ucs {
+ pub name: String,
+ pub origin: Vec3,
+ pub x_axis: Vec3,
+ pub y_axis: Vec3,
+}
+
+/// A saved layer state (LAYERSTATE).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct LayerState {
+ pub name: String,
+ pub layers: Vec,
+}
+
+/// A named group of entities (GROUP).
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Group {
+ pub name: String,
+ pub description: String,
+ pub selectable: bool,
+ pub members: Vec,
+}
+
+/// Shared ownership helper: blocks are cloned on write.
+pub type BlockRef = Arc;
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
new file mode 100644
index 00000000..91334ac6
--- /dev/null
+++ b/crates/doc/src/tests.rs
@@ -0,0 +1,108 @@
+use crate::*;
+use cadcraft_geom::{Mat3, Vec2, Vec3};
+
+fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
+ EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
+}
+
+#[test]
+fn new_drawing_has_standard_tables() {
+ let d = Drawing::new_imperial();
+ assert!(d.layer("0").is_some());
+ assert!(d.linetype("continuous").is_some());
+ assert!(d.text_style("Standard").is_some());
+ assert_eq!(d.header.f64("TEXTSIZE", 0.0), 0.2);
+ let m = Drawing::new_metric();
+ assert!(m.dim_style("ISO-25").is_some());
+}
+
+#[test]
+fn add_and_extents() {
+ let mut d = Drawing::new_imperial();
+ d.add(&Space::Model, Common::default(), line((0.0, 0.0), (10.0, 5.0))).unwrap();
+ d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0 })).unwrap();
+ let b = d.extents(&Space::Model);
+ assert_eq!(b.min, Vec2::new(0.0, -2.0));
+ assert_eq!(b.max, Vec2::new(22.0, 5.0));
+}
+
+#[test]
+fn hidden_layers_excluded_from_extents() {
+ let mut d = Drawing::new_imperial();
+ let c = Common { layer: "Hidden".into(), ..Default::default() };
+ d.add(&Space::Model, c, line((100.0, 100.0), (200.0, 200.0))).unwrap();
+ d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
+ d.layer_mut("Hidden").unwrap().on = false;
+ assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
+}
+
+#[test]
+fn transform_arc_mirror_keeps_shape() {
+ let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 });
+ k.transform(&Mat3::mirror(Vec2::ZERO, Vec2::Y));
+ if let EntityKind::Arc(a) = k {
+ let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
+ assert!(g.mid_point().near(Vec2::from_angle(3.0 * std::f64::consts::FRAC_PI_4), 1e-9));
+ } else {
+ panic!()
+ }
+}
+
+#[test]
+fn insert_bounds_with_cyclic_block_terminate() {
+ let mut d = Drawing::new_imperial();
+ let mut b = Block::new("A");
+ b.entities.push(Entity::new(
+ Handle(1),
+ EntityKind::Insert(Insert {
+ block: "A".into(),
+ insert: Vec3::ZERO,
+ scale: Vec3::new(1.0, 1.0, 1.0),
+ rotation: 0.0,
+ attribs: vec![],
+ cols: 1,
+ rows: 1,
+ col_spacing: 0.0,
+ row_spacing: 0.0,
+ }),
+ ));
+ b.entities.push(Entity::new(Handle(2), line((0.0, 0.0), (1.0, 1.0))));
+ d.blocks.insert("A".into(), std::sync::Arc::new(b));
+ let h = d
+ .add(
+ &Space::Model,
+ Common::default(),
+ EntityKind::Insert(Insert {
+ block: "A".into(),
+ insert: Vec3::new(5.0, 5.0, 0.0),
+ scale: Vec3::new(2.0, 2.0, 1.0),
+ rotation: 0.0,
+ attribs: vec![],
+ cols: 1,
+ rows: 1,
+ col_spacing: 0.0,
+ row_spacing: 0.0,
+ }),
+ )
+ .unwrap();
+ let e = d.entity(h).unwrap().clone();
+ let bb = entity_bounds(&d, &e, 0);
+ assert_eq!(bb.max, Vec2::new(7.0, 7.0));
+}
+
+#[test]
+fn entity_serde_roundtrip() {
+ let e = Entity::new(Handle(7), line((1.0, 2.0), (3.0, 4.0)));
+ let s = serde_json::to_string(&e).unwrap();
+ let back: Entity = serde_json::from_str(&s).unwrap();
+ assert_eq!(e, back);
+}
+
+#[test]
+fn paper_space_entities() {
+ let mut d = Drawing::new_imperial();
+ let h = d.add(&Space::Paper("Layout1".into()), Common::default(), line((0.0, 0.0), (1.0, 0.0))).unwrap();
+ assert_eq!(d.space_of(h), Some(Space::Paper("Layout1".into())));
+ assert!(d.remove_entity(h).is_some());
+ assert_eq!(d.entity_count(), 0);
+}
diff --git a/crates/dxf/Cargo.toml b/crates/dxf/Cargo.toml
new file mode 100644
index 00000000..84bfce4f
--- /dev/null
+++ b/crates/dxf/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "cadcraft-dxf"
+description = "Standalone DXF tag reader/writer (ASCII and binary), from the published DXF reference"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+thiserror = { workspace = true }
diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs
new file mode 100644
index 00000000..c54cf2f8
--- /dev/null
+++ b/crates/dxf/src/lib.rs
@@ -0,0 +1,458 @@
+//! A standalone DXF tag reader and writer.
+//!
+//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a
+//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping
+//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF
+//! Reference; depends on nothing else in the workspace.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#![forbid(unsafe_code)]
+
+use std::fmt::Write as _;
+
+#[derive(Debug, thiserror::Error)]
+pub enum DxfError {
+ #[error("not a DXF file")]
+ NotDxf,
+ #[error("line {line}: bad group code `{text}`")]
+ BadCode { line: usize, text: String },
+ #[error("unexpected end of file")]
+ Eof,
+ #[error("file too large")]
+ TooLarge,
+}
+
+pub type Result = std::result::Result;
+
+/// A typed group value.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+ Str(String),
+ Real(f64),
+ Int(i64),
+ Bool(bool),
+ /// Binary chunk (310–319, 1004) as hex text.
+ Hex(String),
+}
+
+impl Value {
+ pub fn as_str(&self) -> String {
+ match self {
+ Value::Str(s) | Value::Hex(s) => s.clone(),
+ Value::Real(r) => format_real(*r),
+ Value::Int(i) => i.to_string(),
+ Value::Bool(b) => i64::from(*b).to_string(),
+ }
+ }
+ pub fn as_f64(&self) -> f64 {
+ match self {
+ Value::Real(r) if r.is_finite() => *r,
+ Value::Int(i) => *i as f64,
+ Value::Str(s) => s.trim().parse::().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
+ Value::Bool(b) => f64::from(u8::from(*b)),
+ _ => 0.0,
+ }
+ }
+ pub fn as_i64(&self) -> i64 {
+ match self {
+ Value::Int(i) => *i,
+ Value::Real(r) if r.is_finite() => *r as i64,
+ Value::Str(s) => s.trim().parse::().unwrap_or_else(|_| s.trim().parse::().map(|f| f as i64).unwrap_or(0)),
+ Value::Bool(b) => i64::from(*b),
+ _ => 0,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Tag {
+ pub code: i32,
+ pub value: Value,
+}
+
+impl Tag {
+ pub fn new(code: i32, value: Value) -> Self {
+ Tag { code, value }
+ }
+ pub fn s(code: i32, v: impl Into) -> Self {
+ Tag { code, value: Value::Str(v.into()) }
+ }
+ pub fn f(code: i32, v: f64) -> Self {
+ Tag { code, value: Value::Real(if v.is_finite() { v } else { 0.0 }) }
+ }
+ pub fn i(code: i32, v: i64) -> Self {
+ Tag { code, value: Value::Int(v) }
+ }
+ pub fn str(&self) -> String {
+ self.value.as_str()
+ }
+ pub fn f64(&self) -> f64 {
+ self.value.as_f64()
+ }
+ pub fn i64(&self) -> i64 {
+ self.value.as_i64()
+ }
+}
+
+/// The value type for a group code (DXF Reference, "Group Code Value Types").
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+ Str,
+ Real,
+ Int16,
+ Int32,
+ Int64,
+ Bool,
+ Hex,
+}
+
+pub fn kind_of(code: i32) -> Kind {
+ match code {
+ 0..=9 => Kind::Str,
+ 10..=59 => Kind::Real,
+ 60..=79 => Kind::Int16,
+ 90..=99 => Kind::Int32,
+ 100..=109 => Kind::Str,
+ 110..=149 => Kind::Real,
+ 160..=169 => Kind::Int64,
+ 170..=179 => Kind::Int16,
+ 210..=239 => Kind::Real,
+ 270..=289 => Kind::Int16,
+ 290..=299 => Kind::Bool,
+ 300..=309 => Kind::Str,
+ 310..=319 => Kind::Hex,
+ 320..=369 => Kind::Str,
+ 370..=389 => Kind::Int16,
+ 390..=399 => Kind::Str,
+ 400..=409 => Kind::Int16,
+ 410..=419 => Kind::Str,
+ 420..=429 => Kind::Int32,
+ 430..=439 => Kind::Str,
+ 440..=449 => Kind::Int32,
+ 450..=459 => Kind::Int32,
+ 460..=469 => Kind::Real,
+ 470..=479 => Kind::Str,
+ 480..=481 => Kind::Str,
+ 999 => Kind::Str,
+ 1000..=1003 => Kind::Str,
+ 1004 => Kind::Hex,
+ 1005..=1009 => Kind::Str,
+ 1010..=1059 => Kind::Real,
+ 1060..=1070 => Kind::Int16,
+ 1071 => Kind::Int32,
+ _ => Kind::Str,
+ }
+}
+
+fn typed(code: i32, text: &str) -> Value {
+ let t = text.trim();
+ match kind_of(code) {
+ Kind::Real => t.parse::().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
+ Kind::Int16 | Kind::Int32 | Kind::Int64 => {
+ t.parse::().map(Value::Int).unwrap_or_else(|_| Value::Int(t.parse::().map(|f| f as i64).unwrap_or(0)))
+ }
+ Kind::Bool => Value::Bool(t.parse::().unwrap_or(0) != 0),
+ Kind::Hex => Value::Hex(t.to_string()),
+ Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
+ }
+}
+
+const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
+const MAX_TAGS: usize = 50_000_000;
+
+/// Decode bytes as text: UTF-8 when valid, otherwise Latin-1 (old ANSI_1252 files).
+fn decode_text(b: &[u8]) -> String {
+ match std::str::from_utf8(b) {
+ Ok(s) => s.to_string(),
+ Err(_) => b.iter().map(|&c| char::from(c)).collect(),
+ }
+}
+
+/// Parse a DXF file (ASCII or binary).
+pub fn parse(bytes: &[u8]) -> Result> {
+ if bytes.starts_with(BINARY_SENTINEL) {
+ return parse_binary(bytes.get(BINARY_SENTINEL.len()..).unwrap_or(&[]));
+ }
+ parse_ascii(&decode_text(bytes))
+}
+
+/// Unescape `\U+XXXX` sequences used by DXF R2007+ for non-ASCII characters.
+fn unescape_unicode(s: &str) -> String {
+ if !s.contains("\\U+") {
+ return s.to_string();
+ }
+ let mut out = String::with_capacity(s.len());
+ let chars: Vec = s.chars().collect();
+ let mut i = 0;
+ while let Some(&c) = chars.get(i) {
+ if c == '\\' && chars.get(i + 1) == Some(&'U') && chars.get(i + 2) == Some(&'+') {
+ let hex: String = chars.iter().skip(i + 3).take(4).collect();
+ if hex.len() == 4
+ && let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)
+ {
+ out.push(ch);
+ i += 7;
+ continue;
+ }
+ }
+ out.push(c);
+ i += 1;
+ }
+ out
+}
+
+#[allow(clippy::while_let_loop)]
+pub fn parse_ascii(text: &str) -> Result> {
+ let mut tags = Vec::new();
+ let mut lines = text.lines().enumerate();
+ loop {
+ let Some((ln, code_line)) = lines.next() else { break };
+ let ct = code_line.trim();
+ if ct.is_empty() {
+ continue;
+ }
+ let code: i32 = ct.parse().map_err(|_| DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() })?;
+ let Some((_, val)) = lines.next() else { return Err(DxfError::Eof) };
+ let v = typed(code, val);
+ let v = match v {
+ Value::Str(s) => Value::Str(unescape_unicode(&s)),
+ o => o,
+ };
+ tags.push(Tag { code, value: v });
+ if tags.len() > MAX_TAGS {
+ return Err(DxfError::TooLarge);
+ }
+ if code == 0 && tags.last().is_some_and(|t| t.str() == "EOF") {
+ break;
+ }
+ }
+ if tags.is_empty() {
+ return Err(DxfError::NotDxf);
+ }
+ Ok(tags)
+}
+
+struct Cur<'a> {
+ b: &'a [u8],
+ p: usize,
+}
+
+impl Cur<'_> {
+ fn take(&mut self, n: usize) -> Result<&[u8]> {
+ let end = self.p.checked_add(n).ok_or(DxfError::Eof)?;
+ let s = self.b.get(self.p..end).ok_or(DxfError::Eof)?;
+ self.p = end;
+ Ok(s)
+ }
+ fn u8(&mut self) -> Result {
+ Ok(*self.take(1)?.first().ok_or(DxfError::Eof)?)
+ }
+ fn i16(&mut self) -> Result {
+ let s = self.take(2)?;
+ Ok(i16::from_le_bytes([s[0], s[1]]))
+ }
+ fn i32(&mut self) -> Result {
+ let s = self.take(4)?;
+ Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]]))
+ }
+ fn i64(&mut self) -> Result {
+ let s = self.take(8)?;
+ let mut a = [0u8; 8];
+ a.copy_from_slice(s);
+ Ok(i64::from_le_bytes(a))
+ }
+ fn f64(&mut self) -> Result {
+ let s = self.take(8)?;
+ let mut a = [0u8; 8];
+ a.copy_from_slice(s);
+ Ok(f64::from_le_bytes(a))
+ }
+ fn cstr(&mut self) -> Result {
+ let start = self.p;
+ while self.p < self.b.len() && self.b.get(self.p) != Some(&0) {
+ self.p += 1;
+ }
+ let s = decode_text(self.b.get(start..self.p).unwrap_or(&[]));
+ if self.p >= self.b.len() {
+ return Err(DxfError::Eof);
+ }
+ self.p += 1;
+ Ok(s)
+ }
+}
+
+/// Binary DXF (R13+: 2-byte group codes).
+pub fn parse_binary(b: &[u8]) -> Result> {
+ let mut c = Cur { b, p: 0 };
+ let mut tags = Vec::new();
+ while c.p < b.len() {
+ let mut code = i32::from(c.i16()?);
+ if code == 255 {
+ code = i32::from(c.i16()?);
+ }
+ let v = match kind_of(code) {
+ Kind::Str => Value::Str(c.cstr()?),
+ Kind::Real => Value::Real(c.f64()?),
+ Kind::Int16 => Value::Int(i64::from(c.i16()?)),
+ Kind::Int32 => Value::Int(i64::from(c.i32()?)),
+ Kind::Int64 => Value::Int(c.i64()?),
+ Kind::Bool => Value::Bool(c.u8()? != 0),
+ Kind::Hex => {
+ let n = usize::from(c.u8()?);
+ let bytes = c.take(n)?;
+ let mut s = String::with_capacity(n * 2);
+ for x in bytes {
+ let _ = write!(s, "{x:02X}");
+ }
+ Value::Hex(s)
+ }
+ };
+ let eof = code == 0 && matches!(&v, Value::Str(s) if s == "EOF");
+ tags.push(Tag { code, value: v });
+ if tags.len() > MAX_TAGS {
+ return Err(DxfError::TooLarge);
+ }
+ if eof {
+ break;
+ }
+ }
+ Ok(tags)
+}
+
+/// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point).
+pub fn format_real(v: f64) -> String {
+ if !v.is_finite() {
+ return "0.0".into();
+ }
+ let s = format!("{v}");
+ if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") }
+}
+
+/// Write tags as ASCII DXF (CRLF line ends, codes right-aligned to 3 as is conventional).
+pub fn write_ascii(tags: &[Tag]) -> String {
+ let mut out = String::with_capacity(tags.len() * 16);
+ for t in tags {
+ let _ = write!(out, "{:>3}\r\n", t.code);
+ let v = match &t.value {
+ Value::Str(s) => s.replace(['\r', '\n'], " "),
+ Value::Real(r) => format_real(*r),
+ Value::Int(i) => i.to_string(),
+ Value::Bool(b) => i64::from(*b).to_string(),
+ Value::Hex(h) => h.clone(),
+ };
+ out.push_str(&v);
+ out.push_str("\r\n");
+ }
+ out
+}
+
+/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
+#[derive(Clone, Debug, Default)]
+pub struct Section {
+ pub name: String,
+ pub tags: Vec,
+}
+
+/// Split a tag stream into sections.
+pub fn sections(tags: &[Tag]) -> Vec {
+ let mut out = Vec::new();
+ let mut i = 0;
+ while i < tags.len() {
+ let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str() == "SECTION");
+ if is_sec {
+ let name = tags.get(i + 1).filter(|t| t.code == 2).map(Tag::str).unwrap_or_default();
+ let start = i + 2;
+ let mut j = start;
+ while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str() == "ENDSEC")) {
+ j += 1;
+ }
+ out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
+ i = j + 1;
+ } else {
+ i += 1;
+ }
+ }
+ out
+}
+
+/// Split a run of tags into records starting at each code-0 tag: (type, tags after the 0).
+pub fn records(tags: &[Tag]) -> Vec<(String, Vec)> {
+ let mut out: Vec<(String, Vec)> = Vec::new();
+ for t in tags {
+ if t.code == 0 {
+ out.push((t.str(), Vec::new()));
+ } else if let Some(last) = out.last_mut() {
+ last.1.push(t.clone());
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ascii_roundtrip() {
+ let tags = vec![
+ Tag::s(0, "SECTION"),
+ Tag::s(2, "ENTITIES"),
+ Tag::s(0, "LINE"),
+ Tag::s(8, "0"),
+ Tag::f(10, 1.5),
+ Tag::f(20, 2.0),
+ Tag::i(62, 1),
+ Tag::s(0, "ENDSEC"),
+ Tag::s(0, "EOF"),
+ ];
+ let text = write_ascii(&tags);
+ let back = parse(text.as_bytes()).unwrap();
+ assert_eq!(back, tags);
+ let secs = sections(&back);
+ assert_eq!(secs.len(), 1);
+ assert_eq!(secs[0].name, "ENTITIES");
+ let recs = records(&secs[0].tags);
+ assert_eq!(recs[0].0, "LINE");
+ assert_eq!(recs[0].1.len(), 4);
+ }
+
+ #[test]
+ fn binary_parse() {
+ let mut b = BINARY_SENTINEL.to_vec();
+ let push_str = |b: &mut Vec, code: i16, s: &str| {
+ b.extend_from_slice(&code.to_le_bytes());
+ b.extend_from_slice(s.as_bytes());
+ b.push(0);
+ };
+ push_str(&mut b, 0, "LINE");
+ b.extend_from_slice(&10i16.to_le_bytes());
+ b.extend_from_slice(&3.25f64.to_le_bytes());
+ b.extend_from_slice(&62i16.to_le_bytes());
+ b.extend_from_slice(&5i16.to_le_bytes());
+ push_str(&mut b, 0, "EOF");
+ let tags = parse(&b).unwrap();
+ assert_eq!(tags[1], Tag::f(10, 3.25));
+ assert_eq!(tags[2], Tag::i(62, 5));
+ }
+
+ #[test]
+ fn malformed_inputs() {
+ assert!(parse(b"").is_err());
+ assert!(parse(b"abc\nxyz\n").is_err());
+ assert!(parse(b"0\n").is_err());
+ let mut b = BINARY_SENTINEL.to_vec();
+ b.extend_from_slice(&10i16.to_le_bytes());
+ b.extend_from_slice(&[1, 2, 3]);
+ assert!(parse(&b).is_err());
+ // Bad numbers become 0 rather than failing.
+ let t = parse(b"10\nnotanumber\n0\nEOF\n").unwrap();
+ assert_eq!(t[0].f64(), 0.0);
+ }
+
+ #[test]
+ fn unicode_escapes_and_latin1() {
+ let t = parse(b"1\nCaf\\U+00E9\n0\nEOF\n").unwrap();
+ assert_eq!(t[0].str(), "Café");
+ let t = parse(b"1\nCaf\xe9\n0\nEOF\n").unwrap();
+ assert_eq!(t[0].str(), "Café");
+ }
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
new file mode 100644
index 00000000..edfde56d
--- /dev/null
+++ b/crates/engine/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "cadcraft-engine"
+description = "CADCraft engine: sessions, commands, the command line and prompts, snaps, selection, undo"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+cadcraft-geom = { workspace = true }
+cadcraft-color = { workspace = true }
+cadcraft-doc = { workspace = true }
+cadcraft-fonts = { workspace = true }
+cadcraft-render = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+log = { workspace = true }
diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs
new file mode 100644
index 00000000..126c2a70
--- /dev/null
+++ b/crates/engine/src/cmd/draw.rs
@@ -0,0 +1,1406 @@
+//! Draw commands: LINE, PLINE, CIRCLE, ARC, RECTANG, POLYGON, ELLIPSE, POINT, XLINE, RAY,
+//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
+
+use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
+use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, Vec3, arc_to_bulge, bulge_to_arc};
+use serde_json::{Value, json};
+
+use super::helpers::*;
+use super::machines::number;
+use super::*;
+use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+
+pub fn specs() -> Vec {
+ vec![
+ CommandSpec::new("line", "Line", run_line)
+ .menu(&["Draw", "Line"])
+ .alias(&["l"])
+ .params("{points: [[x,y],...], closed?: bool}")
+ .interactive(|_| Ok(Box::new(LineM::default()))),
+ CommandSpec::new("pline", "Polyline", run_pline)
+ .menu(&["Draw", "Polyline"])
+ .alias(&["pl"])
+ .params("{vertices: [[x,y] | {p:[x,y], bulge, startWidth, endWidth}], closed?, width?}")
+ .interactive(|_| Ok(Box::new(PlineM::default()))),
+ CommandSpec::new("circle", "Circle", run_circle)
+ .menu(&["Draw", "Circle", "Center, Radius"])
+ .alias(&["c"])
+ .params("{center, radius} | {center, diameter} | {p1, p2} | {p1, p2, p3}")
+ .interactive(|_| Ok(Box::new(CircleM::default()))),
+ CommandSpec::new("arc", "Arc", run_arc)
+ .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)
+ .menu(&["Draw", "Rectangle"])
+ .alias(&["rec", "rectangle"])
+ .params("{p1, p2, fillet?, chamfer?: [d1, d2], width?}")
+ .interactive(|_| Ok(Box::new(RectM::default()))),
+ CommandSpec::new("polygon", "Polygon", run_polygon)
+ .menu(&["Draw", "Polygon"])
+ .alias(&["pol"])
+ .params("{sides, center, radius, inscribed?: bool, angle?} | {sides, edge: [[x,y],[x,y]]}")
+ .interactive(|_| Ok(Box::new(PolygonM::default()))),
+ CommandSpec::new("ellipse", "Ellipse", run_ellipse)
+ .menu(&["Draw", "Ellipse", "Center"])
+ .alias(&["el"])
+ .params("{center, major: [dx,dy], ratio, start?, end? (degrees)}")
+ .interactive(|_| Ok(Box::new(EllipseM::default()))),
+ CommandSpec::new("point", "Point", run_point)
+ .menu(&["Draw", "Point", "Single Point"])
+ .alias(&["po"])
+ .params("{at: [x,y]} | {points: [...]}")
+ .interactive(|_| Ok(Box::new(PointM { multiple: false }))),
+ CommandSpec::new("point.multiple", "Multiple Point", run_point)
+ .menu(&["Draw", "Point", "Multiple Point"])
+ .params("{points: [...]}")
+ .interactive(|_| Ok(Box::new(PointM { multiple: true }))),
+ CommandSpec::new("xline", "Construction Line", run_xline)
+ .menu(&["Draw", "Construction Line"])
+ .alias(&["xl"])
+ .params("{base, through} | {base, angle (degrees)}")
+ .interactive(|_| Ok(Box::new(XlineM { ray: false, base: None, fixed_angle: None }))),
+ CommandSpec::new("ray", "Ray", run_ray)
+ .menu(&["Draw", "Ray"])
+ .params("{base, through}")
+ .interactive(|_| Ok(Box::new(XlineM { ray: true, base: None, fixed_angle: None }))),
+ CommandSpec::new("spline", "Spline", run_spline)
+ .menu(&["Draw", "Spline", "Fit Points"])
+ .alias(&["spl"])
+ .params("{fit: [[x,y],...]} | {control: [...], degree?}")
+ .interactive(|_| Ok(Box::new(SplineM::default()))),
+ CommandSpec::new("donut", "Donut", run_donut)
+ .menu(&["Draw", "Donut"])
+ .alias(&["do", "doughnut"])
+ .params("{center, inside, outside}")
+ .interactive(|_| Ok(Box::new(DonutM::default()))),
+ CommandSpec::new("text", "Single Line Text", run_text)
+ .menu(&["Draw", "Text", "Single Line Text"])
+ .alias(&["dt", "dtext"])
+ .params("{at, text, height?, rotation? (degrees), justify?: L|C|R|M|TL|TC|TR|ML|MC|MR|BL|BC|BR}")
+ .interactive(|s| Ok(Box::new(TextM::new(s)))),
+ CommandSpec::new("mtext", "Multiline Text", run_mtext)
+ .menu(&["Draw", "Text", "Multiline Text..."])
+ .alias(&["t", "mt"])
+ .params("{at, text, height?, width?, attach?: 1..9, rotation?}")
+ .interactive(|_| Ok(Box::new(MTextM::default()))),
+ ]
+}
+
+fn added(h: cadcraft_doc::Handle) -> Result {
+ Ok(json!({ "handle": h.hex() }))
+}
+
+// ---------------- JSON forms ----------------
+
+fn run_line(s: &mut Session, p: &Value) -> Result {
+ let pts = points_param(p, "points").ok_or_else(|| bad("line", "`points` [[x,y],...] (2 or more) is required"))?;
+ if pts.len() < 2 {
+ return Err(bad("line", "need at least 2 points"));
+ }
+ let mut hs = Vec::new();
+ for w in pts.windows(2) {
+ if let [a, b] = w {
+ hs.push(s.add_entity(line(*a, *b))?.hex());
+ }
+ }
+ if bool_or(p, "closed", false)
+ && pts.len() > 2
+ && let (Some(a), Some(b)) = (pts.last(), pts.first())
+ {
+ hs.push(s.add_entity(line(*a, *b))?.hex());
+ }
+ Ok(json!({ "handles": hs }))
+}
+
+fn vertex_value(v: &Value) -> Option {
+ if let Some(p) = point_value(v) {
+ return Some(PolyVertex::new(p));
+ }
+ let p = point_value(v.get("p")?)?;
+ Some(PolyVertex { p, bulge: f64_or(v, "bulge", 0.0), start_width: f64_or(v, "startWidth", 0.0), end_width: f64_or(v, "endWidth", 0.0) })
+}
+
+fn run_pline(s: &mut Session, p: &Value) -> Result {
+ let vs: Vec = p
+ .get("vertices")
+ .or_else(|| p.get("points"))
+ .and_then(Value::as_array)
+ .map(|a| a.iter().filter_map(vertex_value).collect())
+ .unwrap_or_default();
+ if vs.len() < 2 {
+ return Err(bad("pline", "`vertices` needs at least 2 points"));
+ }
+ let mut k = lwpoly(vs, bool_or(p, "closed", false));
+ 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_circle(s: &mut Session, p: &Value) -> Result {
+ let c = if let Some(center) = point_param(p, "center") {
+ let r = p
+ .get("radius")
+ .and_then(Value::as_f64)
+ .or_else(|| p.get("diameter").and_then(Value::as_f64).map(|d| d / 2.0))
+ .ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))?;
+ Circle::new(center, r)
+ } 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, radius} or {p1, p2[, p3]}"));
+ };
+ if c.radius <= 0.0 || !c.radius.is_finite() {
+ return Err(bad("circle", "radius must be positive"));
+ }
+ added(s.add_entity(circle(c.center, c.radius))?)
+}
+
+fn run_arc(s: &mut Session, p: &Value) -> Result {
+ let a = if let (Some(a), Some(b), Some(c)) = (point_param(p, "p1"), point_param(p, "p2"), point_param(p, "p3")) {
+ Arc::from_3_points(a, b, c).ok_or_else(|| bad("arc", "points are collinear"))?
+ } else if let (Some(st), Some(c), Some(e)) = (point_param(p, "start"), point_param(p, "center"), point_param(p, "end")) {
+ Arc::from_start_center_end(st, c, e)
+ } else if let Some(c) = point_param(p, "center") {
+ let r = f64_req("arc", p, "radius")?;
+ Arc::new(c, r, f64_req("arc", p, "start")?.to_radians(), f64_req("arc", p, "end")?.to_radians())
+ } else {
+ return Err(bad("arc", "give {p1,p2,p3}, {start,center,end} or {center,radius,start,end}"));
+ };
+ 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) {
+ return Err(bad("polygon", "sides must be 3..1024"));
+ }
+ let vs = if let Some(e) = points_param(p, "edge") {
+ let (Some(a), Some(b)) = (e.first(), e.get(1)) else { return Err(bad("polygon", "edge needs 2 points")) };
+ polygon_from_edge(*a, *b, n)
+ } else {
+ let c = point_req("polygon", p, "center")?;
+ let r = f64_req("polygon", p, "radius")?;
+ let ang = f64_or(p, "angle", 90.0).to_radians();
+ let inscribed = bool_or(p, "inscribed", true);
+ polygon_vertices(
+ c,
+ n,
+ Vec2::polar(c, r, if inscribed { ang } else { ang - std::f64::consts::PI / n as f64 + std::f64::consts::PI / n as f64 }),
+ inscribed,
+ )
+ };
+ added(s.add_entity(lwpoly(vs, true))?)
+}
+
+fn run_ellipse(s: &mut Session, p: &Value) -> Result {
+ let c = point_req("ellipse", p, "center")?;
+ let m = point_req("ellipse", p, "major")?;
+ let ratio = f64_req("ellipse", p, "ratio")?;
+ if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 {
+ return Err(bad("ellipse", "ratio must be in (0, 1] and major non-zero"));
+ }
+ let start = f64_or(p, "start", 0.0).to_radians();
+ let end = f64_or(p, "end", 360.0).to_radians();
+ added(s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(c), major: v3(m), ratio, start, end }))?)
+}
+
+fn run_point(s: &mut Session, p: &Value) -> Result {
+ let pts = match point_param(p, "at") {
+ Some(a) => vec![a],
+ None => points_param(p, "points").ok_or_else(|| bad("point", "`at` or `points` is required"))?,
+ };
+ let mut hs = Vec::new();
+ for q in pts {
+ hs.push(s.add_entity(EntityKind::Point(Point { p: v3(q), angle: 0.0 }))?.hex());
+ }
+ 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")?;
+ let dir = (t - b).normalized();
+ if dir == Vec2::ZERO {
+ return Err(bad("ray", "through point equals base"));
+ }
+ added(s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(dir) }))?)
+}
+
+fn run_spline(s: &mut Session, p: &Value) -> Result {
+ let sp = if let Some(f) = points_param(p, "fit") {
+ if f.len() < 2 {
+ return Err(bad("spline", "need 2+ fit points"));
+ }
+ Spline::from_fit_points(&f)
+ } else if let Some(c) = points_param(p, "control") {
+ if c.len() < 2 {
+ return Err(bad("spline", "need 2+ control points"));
+ }
+ Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3) as usize)
+ } else {
+ return Err(bad("spline", "`fit` or `control` points are required"));
+ };
+ added(s.add_entity(EntityKind::Spline(sp))?)
+}
+
+fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind {
+ let r = (inside.abs() + outside.abs()) / 4.0;
+ let w = (outside.abs() - inside.abs()).abs() / 2.0;
+ let mut k = lwpoly(vec![PolyVertex::with_bulge(center - Vec2::X * r, 1.0), PolyVertex::with_bulge(center + Vec2::X * r, 1.0)], true);
+ if let EntityKind::LwPolyline(pl) = &mut k {
+ pl.const_width = w;
+ }
+ k
+}
+
+fn run_donut(s: &mut Session, p: &Value) -> Result {
+ let c = point_req("donut", p, "center")?;
+ added(s.add_entity(donut(c, f64_or(p, "inside", 0.5), f64_or(p, "outside", 1.0)))?)
+}
+
+/// Justification code → (halign, valign).
+pub(crate) fn justify(j: &str) -> Option<(HAlign, VAlign)> {
+ Some(match j.to_ascii_uppercase().as_str() {
+ "L" | "LEFT" => (HAlign::Left, VAlign::Baseline),
+ "C" | "CENTER" => (HAlign::Center, VAlign::Baseline),
+ "R" | "RIGHT" => (HAlign::Right, VAlign::Baseline),
+ "A" | "ALIGN" => (HAlign::Aligned, VAlign::Baseline),
+ "M" | "MIDDLE" => (HAlign::Middle, VAlign::Baseline),
+ "F" | "FIT" => (HAlign::Fit, VAlign::Baseline),
+ "TL" => (HAlign::Left, VAlign::Top),
+ "TC" => (HAlign::Center, VAlign::Top),
+ "TR" => (HAlign::Right, VAlign::Top),
+ "ML" => (HAlign::Left, VAlign::Middle),
+ "MC" => (HAlign::Center, VAlign::Middle),
+ "MR" => (HAlign::Right, VAlign::Middle),
+ "BL" => (HAlign::Left, VAlign::Bottom),
+ "BC" => (HAlign::Center, VAlign::Bottom),
+ "BR" => (HAlign::Right, VAlign::Bottom),
+ _ => return None,
+ })
+}
+
+pub(crate) fn make_text(s: &Session, at: Vec2, value: &str, height: f64, rotation: f64, j: (HAlign, VAlign)) -> EntityKind {
+ let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into());
+ let (h, v) = j;
+ let left = h == HAlign::Left && v == VAlign::Baseline;
+ EntityKind::Text(Text {
+ insert: v3(at),
+ align_pt: if left { None } else { Some(v3(at)) },
+ height,
+ value: value.into(),
+ rotation,
+ width_factor: 1.0,
+ oblique: 0.0,
+ style,
+ halign: h,
+ valign: v,
+ })
+}
+
+fn run_text(s: &mut Session, p: &Value) -> Result {
+ let at = point_req("text", p, "at")?;
+ let text = str_param(p, "text").ok_or_else(|| bad("text", "`text` is required"))?;
+ let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
+ if height <= 0.0 {
+ return Err(bad("text", "height must be positive"));
+ }
+ let j = justify(str_param(p, "justify").unwrap_or("L")).ok_or_else(|| bad("text", "unknown justification"))?;
+ let k = make_text(s, at, text, height, f64_or(p, "rotation", 0.0).to_radians(), j);
+ added(s.add_entity(k)?)
+}
+
+fn run_mtext(s: &mut Session, p: &Value) -> Result {
+ let at = point_req("mtext", p, "at")?;
+ let text = str_param(p, "text").ok_or_else(|| bad("mtext", "`text` is required"))?;
+ let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
+ let style = s.doc()?.header.str("TEXTSTYLE", "Standard");
+ let k = EntityKind::MText(MText {
+ insert: v3(at),
+ height,
+ width: f64_or(p, "width", 0.0).max(0.0),
+ attach: p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
+ rotation: f64_or(p, "rotation", 0.0).to_radians(),
+ style,
+ contents: text.replace('\n', "\\P"),
+ line_spacing: f64_or(p, "lineSpacing", 1.0),
+ });
+ added(s.add_entity(k)?)
+}
+
+// ---------------- interactive ----------------
+
+#[derive(Default)]
+struct LineM {
+ pts: Vec,
+ handles: Vec,
+}
+
+impl Interactive for LineM {
+ fn name(&self) -> &'static str {
+ "LINE"
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ match self.pts.len() {
+ 0 => Prompt::new("Specify first point", Accept::POINT),
+ 1 | 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 {
+ match i {
+ Input::Point(p) => {
+ if let Some(last) = self.pts.last().copied() {
+ if last.near(p, 1e-12) {
+ return Ok(Step::Continue);
+ }
+ self.handles.push(s.add_entity(line(last, p))?);
+ }
+ self.pts.push(p);
+ Ok(Step::Continue)
+ }
+ Input::Keyword(k) if k == "Undo" => {
+ if let Some(h) = self.handles.pop() {
+ s.doc_mut()?.remove_entity(h);
+ }
+ self.pts.pop();
+ Ok(Step::Continue)
+ }
+ Input::Keyword(k) if k == "Close" => {
+ if let (Some(a), Some(b)) = (self.pts.last().copied(), self.pts.first().copied()) {
+ s.add_entity(line(a, b))?;
+ }
+ Ok(Step::Done)
+ }
+ Input::Enter => Ok(Step::Done),
+ _ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
+ }
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default()
+ }
+}
+
+#[derive(Default)]
+struct PlineM {
+ verts: Vec,
+ arc_mode: bool,
+ width: f64,
+ handle: Option,
+ asking_width: u8,
+}
+
+impl PlineM {
+ fn entity(&self, closed: bool) -> EntityKind {
+ let mut k = lwpoly(self.verts.clone(), closed);
+ if let EntityKind::LwPolyline(pl) = &mut k {
+ pl.const_width = self.width;
+ }
+ k
+ }
+ fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> {
+ if self.verts.len() < 2 {
+ if let Some(h) = self.handle.take() {
+ s.doc_mut()?.remove_entity(h);
+ }
+ return Ok(());
+ }
+ let k = self.entity(closed);
+ match self.handle {
+ Some(h) => {
+ s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
+ }
+ None => self.handle = Some(s.add_entity(k)?),
+ }
+ Ok(())
+ }
+ /// Bulge for a tangent-continuing arc from the last vertex to `p`.
+ fn tangent_bulge(&self, p: Vec2) -> f64 {
+ let n = self.verts.len();
+ let Some(last) = self.verts.last() else { return 0.0 };
+ let dir = if n >= 2 {
+ let prev = self.verts.get(n - 2).copied().unwrap_or_default();
+ match bulge_to_arc(prev.p, last.p, prev.bulge) {
+ Some((a, ccw)) => {
+ let r = (last.p - a.center).perp().normalized();
+ if ccw { r } else { -r }
+ }
+ None => (last.p - prev.p).normalized(),
+ }
+ } else {
+ Vec2::X
+ };
+ let chord = p - last.p;
+ // Included angle = 2 × angle between tangent and chord.
+ let ang = dir.cross(chord).atan2(dir.dot(chord));
+ arc_to_bulge(2.0 * ang)
+ }
+}
+
+impl Interactive for PlineM {
+ fn name(&self) -> &'static str {
+ "PLINE"
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ if self.asking_width > 0 {
+ return Prompt::new(if self.asking_width == 1 { "Specify starting width" } else { "Specify ending width" }, Accept::NUMBER)
+ .default(format!("{:.4}", self.width));
+ }
+ let base = self.verts.last().map(|v| v.p);
+ match (self.verts.len(), self.arc_mode) {
+ (0, _) => Prompt::new("Specify start point", Accept::POINT),
+ (n, false) => {
+ let mut kw = vec!["Arc", "Halfwidth", "Length", "Undo", "Width"];
+ if n >= 2 {
+ kw.insert(1, "Close");
+ }
+ Prompt::new("Specify next point", Accept::POINT).kw(&kw).base_opt(base)
+ }
+ (_, true) => Prompt::new("Specify endpoint of arc", Accept::POINT)
+ .kw(&["Angle", "CEnter", "CLose", "Direction", "Halfwidth", "Line", "Radius", "Second pt", "Undo", "Width"])
+ .base_opt(base),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ if self.asking_width > 0 {
+ match i {
+ Input::Text(t) => {
+ let w = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
+ self.width = w.max(0.0);
+ }
+ Input::Enter => {}
+ _ => return Ok(Step::Continue),
+ }
+ self.asking_width = if self.asking_width == 1 { 2 } else { 0 };
+ return Ok(Step::Continue);
+ }
+ match i {
+ Input::Point(p) => {
+ if self.verts.is_empty() {
+ s.echo(format!("Current line-width is {:.4}", self.width));
+ }
+ if self.arc_mode && !self.verts.is_empty() {
+ let b = self.tangent_bulge(p);
+ if let Some(last) = self.verts.last_mut() {
+ last.bulge = b;
+ }
+ }
+ self.verts.push(PolyVertex::new(p));
+ self.sync(s, false)?;
+ Ok(Step::Continue)
+ }
+ Input::Keyword(k) => match k.as_str() {
+ "Arc" => {
+ self.arc_mode = true;
+ Ok(Step::Continue)
+ }
+ "Line" => {
+ self.arc_mode = false;
+ Ok(Step::Continue)
+ }
+ "Close" | "CLose" => {
+ if self.arc_mode
+ && let Some(first) = self.verts.first().map(|v| v.p)
+ {
+ let b = self.tangent_bulge(first);
+ if let Some(last) = self.verts.last_mut() {
+ last.bulge = b;
+ }
+ }
+ self.sync(s, true)?;
+ Ok(Step::Done)
+ }
+ "Undo" => {
+ self.verts.pop();
+ if let Some(l) = self.verts.last_mut() {
+ l.bulge = 0.0;
+ }
+ self.sync(s, false)?;
+ Ok(Step::Continue)
+ }
+ "Width" | "Halfwidth" => {
+ self.asking_width = 1;
+ Ok(Step::Continue)
+ }
+ _ => {
+ s.echo(format!("{k}: not available yet"));
+ Ok(Step::Continue)
+ }
+ },
+ Input::Enter => Ok(Step::Done),
+ _ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
+ }
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ let Some(last) = self.verts.last() else { return Vec::new() };
+ if self.arc_mode {
+ let b = self.tangent_bulge(c);
+ vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)]
+ } else {
+ vec![line(last.p, c)]
+ }
+ }
+}
+
+#[derive(Default)]
+struct CircleM {
+ mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR
+ pts: Vec,
+ diameter: bool,
+}
+
+impl Interactive for CircleM {
+ fn name(&self) -> &'static str {
+ "CIRCLE"
+ }
+ fn prompt(&self, s: &Session) -> Prompt {
+ 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) {
+ (0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
+ (0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()),
+ (0, _) => {
+ let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied());
+ if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
+ }
+ (1, k) => Prompt::new(
+ ["Specify first point on circle", "Specify second point on circle", "Specify third point on circle"].get(k).copied().unwrap_or(""),
+ Accept::POINT,
+ )
+ .base_opt(self.pts.last().copied()),
+ (2, k) => Prompt::new(
+ if k == 0 { "Specify first end point of circle's diameter" } else { "Specify second end point of circle's diameter" },
+ Accept::POINT,
+ )
+ .base_opt(self.pts.last().copied()),
+ _ => Prompt::new("Specify center point for circle", Accept::POINT),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match i {
+ Input::Keyword(k) => {
+ self.mode = match k.as_str() {
+ "3P" => 1,
+ "2P" => 2,
+ "Diameter" => {
+ self.diameter = true;
+ self.mode
+ }
+ _ => {
+ s.echo("Ttr: not available yet");
+ self.mode
+ }
+ };
+ Ok(Step::Continue)
+ }
+ Input::Point(p) => {
+ self.pts.push(p);
+ let done = match self.mode {
+ 0 if self.pts.len() == 2 => {
+ let (Some(c), Some(e)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
+ let r = if self.diameter { c.dist(*e) / 2.0 } else { c.dist(*e) };
+ Some(Circle::new(*c, r))
+ }
+ 1 if self.pts.len() == 3 => Circle::from_3_points(self.pts[0], self.pts[1], self.pts[2]),
+ 2 if self.pts.len() == 2 => Some(Circle::from_2_points(self.pts[0], self.pts[1])),
+ _ => None,
+ };
+ if let Some(c) = done {
+ finish_circle(s, c)?;
+ return Ok(Step::Done);
+ }
+ Ok(Step::Continue)
+ }
+ Input::Text(t) if self.mode == 0 && self.pts.len() == 1 => {
+ let v = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or second point.".into()))?;
+ let r = if self.diameter { v / 2.0 } else { v };
+ if r <= 0.0 {
+ return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
+ }
+ finish_circle(s, Circle::new(self.pts[0], r))?;
+ Ok(Step::Done)
+ }
+ Input::Enter if self.mode == 0 && self.pts.len() == 1 => {
+ let r = s.doc()?.header.f64("CIRCLERAD", 0.0);
+ if r > 0.0 {
+ finish_circle(s, Circle::new(self.pts[0], r))?;
+ Ok(Step::Done)
+ } else {
+ Ok(Step::Continue)
+ }
+ }
+ Input::Enter => Ok(Step::Cancel),
+ _ => Ok(Step::Continue),
+ }
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ match (self.mode, self.pts.as_slice()) {
+ (0, [center]) => {
+ let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) };
+ vec![circle(*center, r), line(*center, c)]
+ }
+ (1, [a, b]) => Circle::from_3_points(*a, *b, c).map(|ci| vec![circle(ci.center, ci.radius)]).unwrap_or_default(),
+ (1, [a]) | (2, [a]) => {
+ let ci = Circle::from_2_points(*a, c);
+ vec![circle(ci.center, ci.radius)]
+ }
+ _ => Vec::new(),
+ }
+ }
+}
+
+fn finish_circle(s: &mut Session, c: Circle) -> Result<()> {
+ s.add_entity(circle(c.center, c.radius))?;
+ s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
+ 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,
+ center: Option,
+ edge: bool,
+ edge_pts: Vec,
+ inscribed: Option,
+}
+
+impl Interactive for PolygonM {
+ fn name(&self) -> &'static str {
+ "POLYGON"
+ }
+ fn prompt(&self, s: &Session) -> Prompt {
+ let def = s.doc().map(|d| d.header.i64("POLYSIDES", 4)).unwrap_or(4);
+ match (self.sides, self.edge, self.center, self.inscribed) {
+ (None, _, _, _) => Prompt::new("Enter number of sides", Accept::NUMBER).default(def.to_string()),
+ (Some(_), true, _, _) => Prompt::new(
+ if self.edge_pts.is_empty() { "Specify first endpoint of edge" } else { "Specify second endpoint of edge" },
+ Accept::POINT,
+ )
+ .base_opt(self.edge_pts.first().copied()),
+ (Some(_), false, None, _) => Prompt::new("Specify center of polygon", Accept::POINT).kw(&["Edge"]),
+ (Some(_), false, Some(_), None) => {
+ Prompt::new("Enter an option", Accept::TEXT).kw(&["Inscribed in circle", "Circumscribed about circle"]).default("I")
+ }
+ (Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ if self.sides.is_none() {
+ let n = match &i {
+ Input::Text(t) => t
+ .trim()
+ .parse::()
+ .ok()
+ .filter(|n| (3..=1024).contains(n))
+ .ok_or_else(|| crate::EngineError::Other("Requires an integer between 3 and 1024.".into()))?,
+ Input::Enter => s.doc()?.header.i64("POLYSIDES", 4).clamp(3, 1024) as usize,
+ _ => return Ok(Step::Continue),
+ };
+ self.sides = Some(n);
+ s.doc_mut()?.header.set_i64("POLYSIDES", n as i64);
+ return Ok(Step::Continue);
+ }
+ let n = self.sides.unwrap_or(4);
+ if self.edge {
+ if let Input::Point(p) = i {
+ self.edge_pts.push(p);
+ if self.edge_pts.len() == 2 {
+ s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?;
+ return Ok(Step::Done);
+ }
+ }
+ return Ok(Step::Continue);
+ }
+ match (self.center, self.inscribed, i) {
+ (None, _, Input::Keyword(k)) if k == "Edge" => self.edge = true,
+ (None, _, Input::Point(p)) => self.center = Some(p),
+ (Some(_), None, Input::Keyword(k)) => self.inscribed = Some(k.starts_with('I')),
+ (Some(_), None, Input::Enter) => self.inscribed = Some(true),
+ (Some(_), None, Input::Text(t)) => self.inscribed = Some(!t.trim().to_ascii_lowercase().starts_with('c')),
+ (Some(c), Some(ins), Input::Point(p)) => {
+ s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?;
+ return Ok(Step::Done);
+ }
+ (Some(c), Some(ins), Input::Text(t)) => {
+ let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?;
+ let rp = if ins { c + Vec2::Y * r } else { c - Vec2::Y * r };
+ s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?;
+ return Ok(Step::Done);
+ }
+ _ => {}
+ }
+ Ok(Step::Continue)
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ let n = self.sides.unwrap_or(4);
+ if self.edge {
+ return self.edge_pts.first().map(|a| vec![lwpoly(polygon_from_edge(*a, c, n), true)]).unwrap_or_default();
+ }
+ match (self.center, self.inscribed) {
+ (Some(ctr), Some(ins)) => vec![lwpoly(polygon_vertices(ctr, n, c, ins), true)],
+ _ => Vec::new(),
+ }
+ }
+}
+
+#[derive(Default)]
+struct EllipseM {
+ center_mode: bool,
+ arc_mode: bool,
+ pts: Vec,
+ ellipse: Option,
+ start: Option,
+}
+
+impl EllipseM {
+ fn axis(&self) -> Option<(Vec2, Vec2)> {
+ // (center, major vector)
+ match (self.center_mode, self.pts.as_slice()) {
+ (true, [c, e, ..]) => Some((*c, *e - *c)),
+ (false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),
+ _ => None,
+ }
+ }
+ fn build(&self, other: Vec2) -> Option {
+ let (c, m) = self.axis()?;
+ let ml = m.len();
+ if ml < 1e-12 {
+ return None;
+ }
+ let u = m / ml;
+ let minor = ((other - c).dot(u.perp())).abs();
+ let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) };
+ (ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0)))
+ }
+}
+
+impl Interactive for EllipseM {
+ fn name(&self) -> &'static str {
+ "ELLIPSE"
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ if let Some(e) = &self.ellipse {
+ return Prompt::new(if self.start.is_none() { "Specify start angle" } else { "Specify end angle" }, Accept::POINT_OR_NUMBER)
+ .kw(&["Parameter"])
+ .base(e.center);
+ }
+ match (self.center_mode, self.pts.len()) {
+ (false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]),
+ (false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()),
+ (true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT),
+ (true, 1) => Prompt::new("Specify endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()),
+ _ => Prompt::new("Specify distance to other axis", Accept::POINT_OR_NUMBER).kw(&["Rotation"]).base_opt(self.axis().map(|a| a.0)),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ if let Some(e) = self.ellipse {
+ let ang = match i {
+ Input::Point(p) => {
+ let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
+ ge.param_of(p)
+ }
+ Input::Text(t) => {
+ crate::units::parse_angle(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))? + e.major.angle()
+ }
+ _ => return Ok(Step::Continue),
+ };
+ match self.start {
+ None => {
+ self.start = Some(ang);
+ return Ok(Step::Continue);
+ }
+ Some(st) => {
+ s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
+ center: v3(e.center),
+ major: v3(e.major),
+ ratio: e.ratio,
+ start: st,
+ end: ang,
+ }))?;
+ return Ok(Step::Done);
+ }
+ }
+ }
+ match i {
+ Input::Keyword(k) if k == "Center" => self.center_mode = true,
+ Input::Keyword(k) if k == "Arc" => self.arc_mode = true,
+ Input::Point(p) => {
+ if self.pts.len() < 2 {
+ self.pts.push(p);
+ } else if let Some(e) = self.build(p) {
+ 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),
+ ratio: e.ratio,
+ start: 0.0,
+ end: cadcraft_geom::TAU,
+ }))?;
+ return Ok(Step::Done);
+ }
+ }
+ Input::Text(t) if self.pts.len() == 2 => {
+ let d = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
+ if let Some((c, m)) = self.axis()
+ && let Some(e) = self.build(c + m.normalized().perp() * d)
+ {
+ s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
+ center: v3(e.center),
+ major: v3(e.major),
+ ratio: e.ratio,
+ start: 0.0,
+ end: cadcraft_geom::TAU,
+ }))?;
+ return Ok(Step::Done);
+ }
+ }
+ Input::Enter => return Ok(Step::Cancel),
+ _ => {}
+ }
+ Ok(Step::Continue)
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ if self.pts.len() < 2 {
+ return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default();
+ }
+ self.build(c)
+ .map(|e| {
+ vec![EntityKind::Ellipse(cadcraft_doc::Ellipse {
+ center: v3(e.center),
+ major: v3(e.major),
+ ratio: e.ratio,
+ start: 0.0,
+ end: cadcraft_geom::TAU,
+ })]
+ })
+ .unwrap_or_default()
+ }
+}
+
+struct PointM {
+ multiple: bool,
+}
+
+impl Interactive for PointM {
+ fn name(&self) -> &'static str {
+ "POINT"
+ }
+ fn prompt(&self, s: &Session) -> Prompt {
+ let d = s.doc().ok();
+ let _ = d;
+ Prompt::new("Specify a point", Accept::POINT)
+ }
+ fn begin(&mut self, s: &mut Session) -> Result {
+ let (m, sz) = s.doc().map(|d| (d.header.i64("PDMODE", 0), d.header.f64("PDSIZE", 0.0))).unwrap_or((0, 0.0));
+ s.echo(format!("Current point modes: PDMODE={m} PDSIZE={sz:.4}"));
+ Ok(Step::Continue)
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match i {
+ Input::Point(p) => {
+ s.add_entity(EntityKind::Point(Point { p: v3(p), angle: 0.0 }))?;
+ Ok(if self.multiple { Step::Continue } else { Step::Done })
+ }
+ Input::Enter => Ok(Step::Done),
+ _ => Ok(Step::Continue),
+ }
+ }
+}
+
+struct XlineM {
+ ray: bool,
+ base: Option,
+ fixed_angle: Option,
+}
+
+impl Interactive for XlineM {
+ fn name(&self) -> &'static str {
+ if self.ray { "RAY" } else { "XLINE" }
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ match (self.base, self.ray) {
+ (None, false) => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]),
+ (None, true) => Prompt::new("Specify start point", Accept::POINT),
+ (Some(b), _) => Prompt::new("Specify through point", Accept::POINT).base(b),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match i {
+ Input::Keyword(k) => {
+ match k.as_str() {
+ "Hor" => self.fixed_angle = Some(0.0),
+ "Ver" => self.fixed_angle = Some(std::f64::consts::FRAC_PI_2),
+ _ => s.echo(format!("{k}: not available yet")),
+ }
+ Ok(Step::Continue)
+ }
+ Input::Point(p) => {
+ if let Some(a) = self.fixed_angle {
+ s.add_entity(EntityKind::XLine(RayLine { base: v3(p), dir: v3(Vec2::from_angle(a)) }))?;
+ return Ok(Step::Continue);
+ }
+ match self.base {
+ None => self.base = Some(p),
+ Some(b) => {
+ let d = (p - b).normalized();
+ if d != Vec2::ZERO {
+ let rl = RayLine { base: v3(b), dir: v3(d) };
+ s.add_entity(if self.ray { EntityKind::Ray(rl) } else { EntityKind::XLine(rl) })?;
+ }
+ }
+ }
+ Ok(Step::Continue)
+ }
+ Input::Enter => Ok(Step::Done),
+ _ => Ok(Step::Continue),
+ }
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ if let Some(a) = self.fixed_angle {
+ return vec![EntityKind::XLine(RayLine { base: v3(c), dir: v3(Vec2::from_angle(a)) })];
+ }
+ match self.base {
+ Some(b) if !b.near(c, 1e-12) => {
+ let rl = RayLine { base: v3(b), dir: v3((c - b).normalized()) };
+ vec![if self.ray { EntityKind::Ray(rl) } else { EntityKind::XLine(rl) }]
+ }
+ _ => Vec::new(),
+ }
+ }
+}
+
+#[derive(Default)]
+struct SplineM {
+ pts: Vec,
+}
+
+impl Interactive for SplineM {
+ fn name(&self) -> &'static str {
+ "SPLINE"
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ match self.pts.len() {
+ 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Knots", "Object"]),
+ 1 => Prompt::new("Enter next point", Accept::POINT).kw(&["start Tangency", "toLerance"]).base_opt(self.pts.last().copied()),
+ 2 => Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo"]).base_opt(self.pts.last().copied()),
+ _ => {
+ Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo", "Close"]).base_opt(self.pts.last().copied())
+ }
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match i {
+ Input::Point(p) => {
+ self.pts.push(p);
+ Ok(Step::Continue)
+ }
+ Input::Keyword(k) if k == "Undo" => {
+ self.pts.pop();
+ Ok(Step::Continue)
+ }
+ Input::Keyword(k) if k == "Close" => {
+ if let Some(f) = self.pts.first().copied() {
+ self.pts.push(f);
+ }
+ let mut sp = Spline::from_fit_points(&self.pts);
+ sp.closed = true;
+ s.add_entity(EntityKind::Spline(sp))?;
+ Ok(Step::Done)
+ }
+ Input::Enter => {
+ if self.pts.len() >= 2 {
+ s.add_entity(EntityKind::Spline(Spline::from_fit_points(&self.pts)))?;
+ }
+ 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_fit_points(&pts))]
+ }
+}
+
+#[derive(Default)]
+struct DonutM {
+ inside: Option,
+ outside: Option,
+}
+
+impl Interactive for DonutM {
+ fn name(&self) -> &'static str {
+ "DONUT"
+ }
+ fn prompt(&self, s: &Session) -> Prompt {
+ let d = s.doc().ok();
+ let di = d.map(|d| d.header.f64("DONUTID", 0.5)).unwrap_or(0.5);
+ let dout = d.map(|d| d.header.f64("DONUTOD", 1.0)).unwrap_or(1.0);
+ match (self.inside, self.outside) {
+ (None, _) => Prompt::new("Specify inside diameter of donut", Accept::NUMBER).default(format!("{di:.4}")),
+ (Some(_), None) => Prompt::new("Specify outside diameter of donut", Accept::NUMBER).default(format!("{dout:.4}")),
+ _ => Prompt::new("Specify center of donut", Accept::POINT).kw(&[]),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ let hdr = |s: &Session, k: &str, d: f64| s.doc().map(|x| x.header.f64(k, d)).unwrap_or(d);
+ match (self.inside, self.outside, i) {
+ (None, _, Input::Text(t)) => {
+ self.inside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs())
+ }
+ (None, _, Input::Enter) => self.inside = Some(hdr(s, "DONUTID", 0.5)),
+ (Some(_), None, Input::Text(t)) => {
+ self.outside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs())
+ }
+ (Some(_), None, Input::Enter) => self.outside = Some(hdr(s, "DONUTOD", 1.0)),
+ (Some(a), Some(b), Input::Point(p)) => {
+ s.add_entity(donut(p, a, b))?;
+ let d = s.doc_mut()?;
+ d.header.set_f64("DONUTID", a);
+ d.header.set_f64("DONUTOD", b);
+ }
+ (Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
+ _ => {}
+ }
+ Ok(Step::Continue)
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ match (self.inside, self.outside) {
+ (Some(a), Some(b)) => vec![donut(c, a, b)],
+ _ => Vec::new(),
+ }
+ }
+}
+
+struct TextM {
+ at: Option,
+ height: Option,
+ rotation: Option,
+ justify: (HAlign, VAlign),
+ asking_justify: bool,
+ line: usize,
+}
+
+impl TextM {
+ fn new(_s: &Session) -> Self {
+ TextM { at: None, height: None, rotation: None, justify: (HAlign::Left, VAlign::Baseline), asking_justify: false, line: 0 }
+ }
+}
+
+impl Interactive for TextM {
+ fn name(&self) -> &'static str {
+ "TEXT"
+ }
+ fn prompt(&self, s: &Session) -> Prompt {
+ let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
+ if self.asking_justify {
+ return Prompt::new("Enter an option", Accept::TEXT)
+ .kw(&["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]);
+ }
+ match (self.at, self.height, self.rotation) {
+ (None, _, _) => Prompt::new("Specify start point of text", Accept::POINT).kw(&["Justify", "Style"]),
+ (Some(a), None, _) => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(a),
+ (Some(a), Some(_), None) => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(a),
+ _ => Prompt::new("Enter text", Accept::TEXT),
+ }
+ }
+ fn begin(&mut self, s: &mut Session) -> Result {
+ let d = s.doc()?;
+ let msg = format!(
+ "Current text style: \"{}\" Text height: {:.4} Annotative: No Justify: Left",
+ d.header.str("TEXTSTYLE", "Standard"),
+ d.header.f64("TEXTSIZE", 0.2)
+ );
+ s.echo(msg);
+ Ok(Step::Continue)
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ if self.asking_justify {
+ let code = match &i {
+ Input::Keyword(k) | Input::Text(k) => k.clone(),
+ _ => return Ok(Step::Continue),
+ };
+ self.justify = justify(&code).unwrap_or(self.justify);
+ self.asking_justify = false;
+ return Ok(Step::Continue);
+ }
+ let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
+ match (self.at, self.height, self.rotation, i) {
+ (None, _, _, Input::Keyword(k)) if k == "Justify" => self.asking_justify = true,
+ (None, _, _, Input::Point(p)) => self.at = Some(p),
+ (Some(_), None, _, Input::Enter) => self.height = Some(th),
+ (Some(a), None, _, Input::Point(p)) => self.height = Some(a.dist(p).max(1e-9)),
+ (Some(_), None, _, Input::Text(t)) => {
+ let h = number(&t).filter(|h| *h > 0.0).ok_or_else(|| crate::EngineError::Other("Requires a positive height.".into()))?;
+ self.height = Some(h);
+ s.doc_mut()?.header.set_f64("TEXTSIZE", h);
+ }
+ (Some(_), Some(_), None, Input::Enter) => self.rotation = Some(0.0),
+ (Some(a), Some(_), None, Input::Point(p)) => self.rotation = Some(a.angle_to(p)),
+ (Some(_), Some(_), None, Input::Text(t)) => {
+ self.rotation = Some(crate::units::parse_angle(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?)
+ }
+ (Some(a), Some(h), Some(r), Input::Text(t)) => {
+ let at = a + Vec2::from_angle(r - std::f64::consts::FRAC_PI_2) * (h * 5.0 / 3.0 * self.line as f64);
+ let k = make_text(s, at, &t, h, r, self.justify);
+ s.add_entity(k)?;
+ self.line += 1;
+ }
+ (Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
+ _ => {}
+ }
+ Ok(Step::Continue)
+ }
+}
+
+#[derive(Default)]
+struct MTextM {
+ first: Option,
+ second: Option,
+}
+
+impl Interactive for MTextM {
+ fn name(&self) -> &'static str {
+ "MTEXT"
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ match (self.first, self.second) {
+ (None, _) => Prompt::new("Specify first corner", Accept::POINT),
+ (Some(a), None) => Prompt::new("Specify opposite corner", Accept::POINT)
+ .kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"])
+ .base(a),
+ _ => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT),
+ }
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match (self.first, self.second, i) {
+ (None, _, Input::Point(p)) => self.first = Some(p),
+ (Some(_), None, Input::Point(p)) => self.second = Some(p),
+ (Some(a), Some(b), Input::Text(t)) => {
+ let d = s.doc()?;
+ let h = d.header.f64("TEXTSIZE", 0.2);
+ let style = d.header.str("TEXTSTYLE", "Standard");
+ let tl = Vec2::new(a.x.min(b.x), a.y.max(b.y));
+ s.add_entity(EntityKind::MText(MText {
+ insert: Vec3::new(tl.x, tl.y, 0.0),
+ height: h,
+ width: (a.x - b.x).abs(),
+ attach: 1,
+ rotation: 0.0,
+ style,
+ contents: t,
+ line_spacing: 1.0,
+ }))?;
+ return Ok(Step::Done);
+ }
+ (_, _, Input::Enter) => return Ok(Step::Done),
+ _ => {}
+ }
+ Ok(Step::Continue)
+ }
+ fn preview(&self, _s: &Session, c: Vec2) -> Vec {
+ match (self.first, self.second) {
+ (Some(a), None) => vec![lwpoly(rect_vertices(a, c), true)],
+ _ => Vec::new(),
+ }
+ }
+}
+
+/// Re-export for tests.
+pub(crate) fn _unused(_: Polyline) {}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
new file mode 100644
index 00000000..b46f23c2
--- /dev/null
+++ b/crates/engine/src/cmd/edit.rs
@@ -0,0 +1,274 @@
+//! Edit menu: undo/redo, clipboard, selection.
+
+use cadcraft_doc::{Entity, Handle, entity_bounds};
+use cadcraft_geom::{Bounds2, Mat3, Vec2};
+use serde_json::{Value, json};
+
+use super::*;
+use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+
+pub fn specs() -> Vec {
+ vec![
+ CommandSpec::new("undo", "Undo", run_undo).menu(&["Edit", "Undo"]).key("Cmd+Z").alias(&["u"]).params("{count?: n}").noundo(),
+ CommandSpec::new("redo", "Redo", run_redo).menu(&["Edit", "Redo"]).key("Cmd+Shift+Z").alias(&["mredo"]).params("{count?: n}").noundo(),
+ CommandSpec::new("cutclip", "Cut", run_cut).menu(&["Edit", "Cut"]).key("Cmd+X").params("{handles?}"),
+ CommandSpec::new("copyclip", "Copy", run_copyclip).menu(&["Edit", "Copy"]).key("Cmd+C").params("{handles?}").noundo(),
+ CommandSpec::new("copybase", "Copy with Base Point", run_copybase)
+ .menu(&["Edit", "Copy with Base Point"])
+ .key("Cmd+Shift+C")
+ .params("{handles?, base: [x,y]}")
+ .noundo(),
+ CommandSpec::new("pasteclip", "Paste", run_paste)
+ .menu(&["Edit", "Paste"])
+ .key("Cmd+V")
+ .params("{at?: [x,y]} (default: same offset as copied)")
+ .interactive(|_| Ok(Box::new(PasteM { orig: false }))),
+ CommandSpec::new("pasteorig", "Paste to Original Coordinates", run_pasteorig).menu(&["Edit", "Paste to Original Coordinates"]).params("{}"),
+ CommandSpec::new("erase.selection", "Clear", run_clear).menu(&["Edit", "Clear"]).key("Delete").enabled(has_selection).params("{}"),
+ CommandSpec::new("selectall", "Select All", run_selectall).menu(&["Edit", "Select All"]).key("Cmd+A").alias(&["ai_selall"]).noundo(),
+ CommandSpec::new("select", "Select", run_select)
+ .params("{handles: [hex]} | {window: [[x,y],[x,y]], crossing?: bool} | {at: [x,y]} | {clear: true} | {add?: bool}")
+ .noundo()
+ .interactive(|_| Ok(Box::new(SelectM::default()))),
+ CommandSpec::new("qselect", "Quick Select...", run_qselect)
+ .menu(&["Edit", "Quick Select..."])
+ .params("{type?: \"Line\"|..., layer?, color?}")
+ .noundo(),
+ ]
+}
+
+fn run_undo(s: &mut Session, p: &Value) -> Result {
+ let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000);
+ let mut labels = Vec::new();
+ for _ in 0..n {
+ match s.undo()? {
+ Some(l) => labels.push(l),
+ None => break,
+ }
+ }
+ if labels.is_empty() {
+ return Ok(json!({ "message": "Everything has been undone" }));
+ }
+ Ok(json!({ "undone": labels, "message": labels.join(", ").to_string() }))
+}
+
+fn run_redo(s: &mut Session, p: &Value) -> Result {
+ let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000);
+ let mut labels = Vec::new();
+ for _ in 0..n {
+ match s.redo()? {
+ Some(l) => labels.push(l),
+ None => break,
+ }
+ }
+ Ok(json!({ "redone": labels, "message": if labels.is_empty() { "Nothing to redo".to_string() } else { labels.join(", ") } }))
+}
+
+fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result {
+ let d = s.doc()?;
+ let ents: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
+ let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
+ s.clipboard_base = base.unwrap_or(b.min);
+ let n = ents.len();
+ s.clipboard = ents;
+ Ok(n)
+}
+
+fn run_copyclip(s: &mut Session, p: &Value) -> Result {
+ let hs = targets(s, p)?;
+ let n = copy_to_clip(s, &hs, None)?;
+ Ok(json!({ "copied": n }))
+}
+
+fn run_copybase(s: &mut Session, p: &Value) -> Result {
+ let hs = targets(s, p)?;
+ let base = point_param(p, "base");
+ let n = copy_to_clip(s, &hs, base)?;
+ Ok(json!({ "copied": n }))
+}
+
+fn run_cut(s: &mut Session, p: &Value) -> Result {
+ let hs = targets(s, p)?;
+ let n = copy_to_clip(s, &hs, None)?;
+ let d = s.doc_mut()?;
+ for h in &hs {
+ d.remove_entity(*h);
+ }
+ s.set_selection(Vec::new());
+ Ok(json!({ "cut": n }))
+}
+
+pub(crate) fn paste(s: &mut Session, offset: Vec2) -> Result> {
+ let clip = s.clipboard.clone();
+ let space = s.space();
+ let m = Mat3::translate(offset);
+ let d = s.doc_mut()?;
+ let mut out = Vec::new();
+ for mut e in clip {
+ e.handle = d.new_handle();
+ e.kind.transform(&m);
+ d.ensure_layer(&e.common.layer);
+ out.push(e.handle);
+ if let Some(st) = d.space_mut(&space) {
+ st.push(e);
+ }
+ }
+ s.set_selection(out.clone());
+ Ok(out)
+}
+
+fn run_paste(s: &mut Session, p: &Value) -> Result {
+ if s.clipboard.is_empty() {
+ return Err(bad("pasteclip", "the clipboard is empty"));
+ }
+ let off = match point_param(p, "at") {
+ Some(at) => at - s.clipboard_base,
+ None => Vec2::ZERO,
+ };
+ let r = paste(s, off)?;
+ Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() }))
+}
+
+fn run_pasteorig(s: &mut Session, _p: &Value) -> Result {
+ let r = paste(s, Vec2::ZERO)?;
+ Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() }))
+}
+
+fn run_clear(s: &mut Session, _p: &Value) -> Result {
+ let hs = s.selection();
+ let d = s.doc_mut()?;
+ let mut n = 0;
+ for h in hs {
+ if d.remove_entity(h).is_some() {
+ n += 1;
+ }
+ }
+ s.set_selection(Vec::new());
+ Ok(json!({ "erased": n }))
+}
+
+fn run_selectall(s: &mut Session, _p: &Value) -> Result {
+ let space = s.space();
+ let d = s.doc()?;
+ let hs: Vec = d
+ .space(&space)
+ .map(|st| st.iter().filter(|e| d.is_visible(e) && d.layer(&e.common.layer).is_none_or(|l| !l.locked)).map(|e| e.handle).collect())
+ .unwrap_or_default();
+ let n = hs.len();
+ s.set_selection(hs);
+ Ok(json!({ "selected": n }))
+}
+
+fn run_select(s: &mut Session, p: &Value) -> Result {
+ if bool_or(p, "clear", false) {
+ s.set_selection(Vec::new());
+ return Ok(json!({ "selected": 0 }));
+ }
+ let space = s.space();
+ let add = bool_or(p, "add", false);
+ let picked: Vec = if let Some(w) = points_param(p, "window") {
+ let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("select", "window needs 2 corners")) };
+ crate::select::select_window(s.doc()?, &space, Bounds2::new(*a, *b), bool_or(p, "crossing", false))
+ } else if let Some(at) = point_param(p, "at") {
+ let ap = f64_or(p, "aperture", s.pixel_size() * s.settings.pickbox * 1.5);
+ crate::select::pick(s.doc()?, &space, at, ap).into_iter().collect()
+ } else if let Some(f) = points_param(p, "fence") {
+ crate::select::select_fence(s.doc()?, &space, &f)
+ } else {
+ targets(s, p)?
+ };
+ let mut sel = if add { s.selection() } else { Vec::new() };
+ sel.extend(picked);
+ s.set_selection(sel);
+ let sel = s.selection();
+ Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::>() }))
+}
+
+fn run_qselect(s: &mut Session, p: &Value) -> Result {
+ let space = s.space();
+ let d = s.doc()?;
+ let ty = str_param(p, "type").map(str::to_ascii_lowercase);
+ let layer = str_param(p, "layer").map(str::to_ascii_lowercase);
+ let color = str_param(p, "color").and_then(cadcraft_color::Color::parse);
+ let hs: Vec = d
+ .space(&space)
+ .map(|st| {
+ st.iter()
+ .filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
+ .filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
+ .filter(|e| color.is_none_or(|c| e.common.color == c))
+ .map(|e| e.handle)
+ .collect()
+ })
+ .unwrap_or_default();
+ let n = hs.len();
+ s.set_selection(hs);
+ Ok(json!({ "selected": n, "message": format!("{n} item(s) selected") }))
+}
+
+struct PasteM {
+ orig: bool,
+}
+
+impl Interactive for PasteM {
+ fn name(&self) -> &'static str {
+ "PASTECLIP"
+ }
+ fn begin(&mut self, s: &mut Session) -> Result {
+ if s.clipboard.is_empty() {
+ s.echo("Clipboard is empty.");
+ return Ok(Step::Done);
+ }
+ if self.orig {
+ paste(s, Vec2::ZERO)?;
+ return Ok(Step::Done);
+ }
+ Ok(Step::Continue)
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ Prompt::new("Specify insertion point", Accept::POINT)
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match i {
+ Input::Point(p) => {
+ let base = s.clipboard_base;
+ paste(s, p - base)?;
+ Ok(Step::Done)
+ }
+ Input::Enter => Ok(Step::Done),
+ _ => Ok(Step::Continue),
+ }
+ }
+ fn preview(&self, s: &Session, c: Vec2) -> Vec {
+ let m = Mat3::translate(c - s.clipboard_base);
+ s.clipboard
+ .iter()
+ .take(500)
+ .map(|e| {
+ let mut k = e.kind.clone();
+ k.transform(&m);
+ k
+ })
+ .collect()
+ }
+}
+
+#[derive(Default)]
+struct SelectM {
+ sel: super::machines::SelectPhase,
+}
+
+impl Interactive for SelectM {
+ fn name(&self) -> &'static str {
+ "SELECT"
+ }
+ fn prompt(&self, _s: &Session) -> Prompt {
+ self.sel.prompt()
+ }
+ fn input(&mut self, s: &mut Session, i: Input) -> Result {
+ match self.sel.feed(s, &i)? {
+ super::machines::SelOutcome::More => Ok(Step::Continue),
+ _ => Ok(Step::Done),
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
new file mode 100644
index 00000000..8ea07b8a
--- /dev/null
+++ b/crates/engine/src/cmd/file.rs
@@ -0,0 +1,198 @@
+//! File menu: new, close, switching drawings. Open/save of DWG/DXF go through the io layer,
+//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`].
+
+use std::sync::OnceLock;
+
+use cadcraft_doc::Drawing;
+use serde_json::{Value, json};
+
+use super::*;
+use crate::{Result, Session};
+
+/// 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>,
+}
+
+static IO: OnceLock = OnceLock::new();
+
+pub fn set_io(h: IoHooks) {
+ let _ = IO.set(h);
+}
+pub fn io() -> Option<&'static IoHooks> {
+ IO.get()
+}
+
+pub fn specs() -> Vec {
+ vec![
+ CommandSpec::new("new", "New Drawing...", run_new)
+ .menu(&["File", "New Drawing..."])
+ .key("Cmd+N")
+ .alias(&["qnew"])
+ .params("{metric?: bool}")
+ .enabled(always)
+ .noundo(),
+ CommandSpec::new("open", "Open...", run_open)
+ .menu(&["File", "Open..."])
+ .key("Cmd+O")
+ .params("{path} | {data: base64, name}")
+ .enabled(always)
+ .noundo(),
+ CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
+ CommandSpec::new("closeall", "Close All", run_closeall).menu(&["File", "Close All"]).enabled(always).noundo(),
+ CommandSpec::new("qsave", "Save", run_save).menu(&["File", "Save"]).key("Cmd+S").alias(&["save"]).params("{path?}").noundo(),
+ CommandSpec::new("saveas", "Save As...", run_saveas)
+ .menu(&["File", "Save As..."])
+ .key("Cmd+Shift+S")
+ .params("{path, format?: dxf|dwg}")
+ .noundo(),
+ CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
+ CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes).params("{format?: dxf} → {data: base64}").noundo(),
+ ]
+}
+
+fn run_new(s: &mut Session, p: &Value) -> Result {
+ let i = s.new_drawing(bool_or(p, "metric", false));
+ Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
+}
+
+fn file_name(path: &str) -> String {
+ std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
+}
+
+pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option) -> Result {
+ let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
+ let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
+ Ok(s.open_drawing(d, &file_name(name), path))
+}
+
+fn run_open(s: &mut Session, p: &Value) -> Result {
+ if let Some(_path) = str_param(p, "path") {
+ #[cfg(not(target_arch = "wasm32"))]
+ let path = _path;
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
+ let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
+ return Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }));
+ }
+ #[cfg(target_arch = "wasm32")]
+ return Err(bad("open", "paths are not available on the web; pass `data`"));
+ }
+ let data = str_param(p, "data").ok_or_else(|| bad("open", "`path` or `data` (base64) is required"))?;
+ let bytes = base64_decode(data).ok_or_else(|| bad("open", "invalid base64"))?;
+ let name = str_param(p, "name").unwrap_or("Drawing.dxf");
+ let i = open_bytes(s, &bytes, name, None)?;
+ Ok(json!({ "index": i }))
+}
+
+fn run_close(s: &mut Session, p: &Value) -> Result {
+ let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);
+ if i >= s.docs.len() {
+ return Err(bad("close", "no such drawing"));
+ }
+ s.cancel();
+ s.docs.remove(i);
+ if s.active >= s.docs.len() {
+ s.active = s.docs.len().saturating_sub(1);
+ }
+ ok()
+}
+
+fn run_closeall(s: &mut Session, _p: &Value) -> Result {
+ s.cancel();
+ s.docs.clear();
+ s.active = 0;
+ ok()
+}
+
+pub(crate) fn save_to(s: &mut Session, path: &str) -> Result {
+ let hooks = io().ok_or_else(|| bad("save", "file formats are not available in this build"))?;
+ let bytes = (hooks.write)(s.doc()?, path).map_err(|e| bad("save", e))?;
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let tmp = format!("{path}.cadcraft-tmp");
+ std::fs::write(&tmp, &bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
+ std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
+ }
+ let st = s.state_mut()?;
+ st.saved = st.doc.clone();
+ st.path = Some(path.to_string());
+ st.title = file_name(path);
+ Ok(bytes.len())
+}
+
+fn run_save(s: &mut Session, p: &Value) -> Result {
+ let path = match str_param(p, "path") {
+ Some(p) => p.to_string(),
+ None => s.state()?.path.clone().ok_or_else(|| bad("qsave", "drawing has no file name yet; use saveas {path}"))?,
+ };
+ let n = save_to(s, &path)?;
+ Ok(json!({ "path": path, "bytes": n }))
+}
+
+fn run_saveas(s: &mut Session, p: &Value) -> Result {
+ let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string();
+ let n = save_to(s, &path)?;
+ Ok(json!({ "path": path, "bytes": n }))
+}
+
+fn run_switch(s: &mut Session, p: &Value) -> Result {
+ let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize;
+ if i >= s.docs.len() {
+ return Err(bad("document.switch", "no such drawing"));
+ }
+ s.cancel();
+ s.active = i;
+ ok()
+}
+
+fn run_bytes(s: &mut Session, p: &Value) -> Result {
+ let hooks = io().ok_or_else(|| bad("document.bytes", "file formats are not available in this build"))?;
+ let fmt = str_param(p, "format").unwrap_or("dxf");
+ let bytes = (hooks.write)(s.doc()?, &format!("drawing.{fmt}")).map_err(|e| bad("document.bytes", e))?;
+ Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len() }))
+}
+
+const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+pub fn base64_encode(data: &[u8]) -> String {
+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
+ for chunk in data.chunks(3) {
+ let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
+ let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
+ for i in 0..4 {
+ if i <= chunk.len() {
+ out.push(char::from(B64[((n >> (18 - 6 * i)) & 63) as usize]));
+ } else {
+ out.push('=');
+ }
+ }
+ }
+ out
+}
+
+pub fn base64_decode(s: &str) -> Option> {
+ let mut out = Vec::with_capacity(s.len() * 3 / 4);
+ let mut buf = 0u32;
+ let mut bits = 0;
+ for c in s.bytes() {
+ let v = match c {
+ b'A'..=b'Z' => c - b'A',
+ b'a'..=b'z' => c - b'a' + 26,
+ b'0'..=b'9' => c - b'0' + 52,
+ b'+' | b'-' => 62,
+ b'/' | b'_' => 63,
+ b'=' | b'\n' | b'\r' | b' ' => continue,
+ _ => return None,
+ };
+ buf = (buf << 6) | u32::from(v);
+ bits += 6;
+ if bits >= 8 {
+ bits -= 8;
+ out.push(((buf >> bits) & 0xff) as u8);
+ }
+ }
+ Some(out)
+}
diff --git a/crates/engine/src/cmd/helpers.rs b/crates/engine/src/cmd/helpers.rs
new file mode 100644
index 00000000..1f64b8fe
--- /dev/null
+++ b/crates/engine/src/cmd/helpers.rs
@@ -0,0 +1,93 @@
+//! Shared geometry helpers for commands.
+
+use cadcraft_doc::{EntityKind, Line};
+use cadcraft_geom::{PolyVertex, Vec2, Vec3};
+
+pub fn v3(p: Vec2) -> Vec3 {
+ p.to3(0.0)
+}
+
+pub fn line(a: Vec2, b: Vec2) -> EntityKind {
+ EntityKind::Line(Line { a: v3(a), b: v3(b) })
+}
+
+pub fn circle(c: Vec2, r: f64) -> EntityKind {
+ EntityKind::Circle(cadcraft_doc::Circle { center: v3(c), radius: r.abs() })
+}
+
+pub fn arc(a: &cadcraft_geom::Arc) -> EntityKind {
+ EntityKind::Arc(cadcraft_doc::Arc { center: v3(a.center), radius: a.radius, start: a.start, end: a.end })
+}
+
+pub fn lwpoly(vs: Vec, closed: bool) -> EntityKind {
+ EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, closed, const_width: 0.0, elevation: 0.0, plinegen: false })
+}
+
+pub fn rect_vertices(a: Vec2, b: Vec2) -> Vec {
+ [a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)].into_iter().map(PolyVertex::new).collect()
+}
+
+/// Regular polygon vertices: inscribed (vertex at `radius`) or circumscribed (edge midpoint at `radius`).
+pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed: bool) -> Vec {
+ let n = sides.clamp(3, 1024);
+ let d = radius_pt - center;
+ let r = d.len();
+ let a0 = d.angle();
+ let step = cadcraft_geom::TAU / n as f64;
+ let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 - step / 2.0) };
+ (0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect()
+}
+
+/// Polygon from an edge (first two vertices).
+pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec {
+ let n = sides.clamp(3, 1024);
+ let step = cadcraft_geom::TAU / n as f64;
+ let mut pts = vec![p1, p2];
+ let len = p1.dist(p2);
+ let mut ang = p1.angle_to(p2);
+ for _ in 2..n {
+ ang += step;
+ let last = pts.last().copied().unwrap_or(p2);
+ pts.push(Vec2::polar(last, len, ang));
+ }
+ pts.into_iter().map(PolyVertex::new).collect()
+}
+
+/// Rectangle vertices with fillet radius or chamfer distances at every corner.
+pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> Vec {
+ let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
+ let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
+ let w = x1 - x0;
+ let h = y1 - y0;
+ if fillet > 0.0 && fillet * 2.0 <= w.min(h) + 1e-12 {
+ let r = fillet;
+ let bul = cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2);
+ return vec![
+ PolyVertex::new(Vec2::new(x0 + r, y0)),
+ PolyVertex::with_bulge(Vec2::new(x1 - r, y0), bul),
+ PolyVertex::new(Vec2::new(x1, y0 + r)),
+ PolyVertex::with_bulge(Vec2::new(x1, y1 - r), bul),
+ PolyVertex::new(Vec2::new(x1 - r, y1)),
+ PolyVertex::with_bulge(Vec2::new(x0 + r, y1), bul),
+ PolyVertex::new(Vec2::new(x0, y1 - r)),
+ PolyVertex::with_bulge(Vec2::new(x0, y0 + r), bul),
+ ];
+ }
+ let (c1, c2) = chamfer;
+ if c1 > 0.0 && c2 > 0.0 && c1 + c1 <= w + 1e-12 && c2 + c2 <= h + 1e-12 {
+ return [
+ Vec2::new(x0 + c1, y0),
+ Vec2::new(x1 - c1, y0),
+ Vec2::new(x1, y0 + c2),
+ Vec2::new(x1, y1 - c2),
+ Vec2::new(x1 - c1, y1),
+ Vec2::new(x0 + c1, y1),
+ Vec2::new(x0, y1 - c2),
+ Vec2::new(x0, y0 + c2),
+ ]
+ .into_iter()
+ .map(PolyVertex::new)
+ .collect();
+ }
+ rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1))
+}
diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs
new file mode 100644
index 00000000..8a987b24
--- /dev/null
+++ b/crates/engine/src/cmd/inquiry.rs
@@ -0,0 +1,450 @@
+//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
+
+use cadcraft_doc::{EntityKind, Handle};
+use cadcraft_geom::{Polyline, Vec2};
+use serde_json::{Value, json};
+
+use super::*;
+use crate::units::{format_angle, format_distance};
+use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+
+pub fn specs() -> Vec {
+ vec![
+ CommandSpec::new("dist", "Distance", run_dist)
+ .menu(&["Tools", "Inquiry", "Distance"])
+ .alias(&["di"])
+ .params("{p1, p2}")
+ .noundo()
+ .transparent()
+ .interactive(|_| Ok(Box::new(DistM::default()))),
+ CommandSpec::new("id", "ID Point", run_id)
+ .menu(&["Tools", "Inquiry", "ID Point"])
+ .params("{at}")
+ .noundo()
+ .transparent()
+ .interactive(|_| Ok(Box::new(IdM))),
+ CommandSpec::new("list", "List", run_list)
+ .menu(&["Tools", "Inquiry", "List"])
+ .alias(&["li", "ls"])
+ .params("{handles?}")
+ .noundo()
+ .interactive(|_| Ok(Box::new(ListM::default()))),
+ CommandSpec::new("area", "Area", run_area)
+ .menu(&["Tools", "Inquiry", "Area"])
+ .alias(&["aa"])
+ .params("{points: [[x,y],...]} | {handle}")
+ .noundo()
+ .interactive(|_| Ok(Box::new(AreaM::default()))),
+ CommandSpec::new("time", "Time", run_time).menu(&["Tools", "Inquiry", "Time"]).noundo(),
+ CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(),
+ CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(),
+ CommandSpec::new("entities", "Query Entities", run_entities).params("{type?, layer?, window?: [[x,y],[x,y]], limit?, offset?}").noundo(),
+ CommandSpec::new("count", "Count", run_count).menu(&["Tools", "Count"]).params("{block?}").noundo(),
+ ]
+}
+
+fn fmt_d(s: &Session, v: f64) -> String {
+ let h = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
+ format_distance(v, h.0, h.1)
+}
+fn fmt_a(s: &Session, v: f64) -> String {
+ let h = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
+ format_angle(v, h.0, h.1)
+}
+
+fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
+ let d = b - a;
+ format!(
+ "Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = 0\nDelta X = {}, Delta Y = {}, Delta Z = {}",
+ fmt_d(s, a.dist(b)),
+ fmt_a(s, d.angle()),
+ fmt_d(s, d.x),
+ fmt_d(s, d.y),
+ fmt_d(s, 0.0)
+ )
+}
+
+fn run_dist(s: &mut Session, p: &Value) -> Result {
+ let a = point_req("dist", p, "p1")?;
+ let b = point_req("dist", p, "p2")?;
+ Ok(json!({ "distance": a.dist(b), "angle": (b - a).angle().to_degrees(), "dx": b.x - a.x, "dy": b.y - a.y, "message": dist_text(s, a, b) }))
+}
+
+fn run_id(s: &mut Session, p: &Value) -> Result {
+ let a = point_req("id", p, "at")?;
+ s.last_point = a;
+ Ok(json!({ "x": a.x, "y": a.y, "message": format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, 0.0)) }))
+}
+
+fn list_text(s: &Session, h: Handle) -> String {
+ let Ok(d) = s.doc() else { return String::new() };
+ let Some(e) = d.entity(h) else { return String::new() };
+ let space = if d.model.contains(h) { "Model space" } else { "Paper space" };
+ let mut t = format!(
+ " {} Layer: \"{}\"\n Space: {}\n Handle = {}",
+ e.kind.dxf_name(),
+ e.common.layer,
+ space,
+ h.hex()
+ );
+ match &e.kind {
+ EntityKind::Line(l) => {
+ t.push_str(&format!(
+ "\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
+ fmt_d(s, l.a.x),
+ fmt_d(s, l.a.y),
+ fmt_d(s, 0.0),
+ fmt_d(s, l.b.x),
+ fmt_d(s, l.b.y),
+ fmt_d(s, 0.0)
+ ));
+ t.push_str(&format!(
+ "\n Length = {}, Angle in XY Plane = {}",
+ fmt_d(s, l.a.xy().dist(l.b.xy())),
+ fmt_a(s, l.a.xy().angle_to(l.b.xy()))
+ ));
+ }
+ EntityKind::Circle(c) => {
+ t.push_str(&format!(
+ "\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
+ fmt_d(s, c.center.x),
+ fmt_d(s, c.center.y),
+ fmt_d(s, 0.0),
+ fmt_d(s, c.radius),
+ fmt_d(s, c.radius * cadcraft_geom::TAU),
+ fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
+ ));
+ }
+ EntityKind::Arc(a) => {
+ t.push_str(&format!(
+ "\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
+ fmt_d(s, a.center.x),
+ fmt_d(s, a.center.y),
+ fmt_d(s, 0.0),
+ fmt_d(s, a.radius),
+ fmt_a(s, a.start),
+ fmt_a(s, a.end)
+ ));
+ }
+ EntityKind::LwPolyline(p) => {
+ let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
+ t.push_str(&format!("\n {}\n Constant width {}", if p.closed { "Closed" } else { "Open" }, fmt_d(s, p.const_width)));
+ if p.closed {
+ t.push_str(&format!("\n area {}\n perimeter {}", fmt_d(s, pl.area().abs()), fmt_d(s, pl.len())));
+ } else {
+ t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
+ }
+ for v in &p.vertices {
+ t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, 0.0)));
+ }
+ }
+ EntityKind::Text(tx) => {
+ t.push_str(&format!(
+ "\n Style = \"{}\"\n start point, X={} Y={}\n height {}\n text {}\n rotation angle {}",
+ tx.style,
+ fmt_d(s, tx.insert.x),
+ fmt_d(s, tx.insert.y),
+ fmt_d(s, tx.height),
+ tx.value,
+ fmt_a(s, tx.rotation)
+ ));
+ }
+ other => {
+ if let Ok(js) = serde_json::to_string(other) {
+ t.push_str(&format!("\n {js}"));
+ }
+ }
+ }
+ t
+}
+
+fn run_list(s: &mut Session, p: &Value) -> Result {
+ let hs = targets(s, p)?;
+ let texts: Vec = hs.iter().map(|h| list_text(s, *h)).collect();
+ Ok(json!({ "message": texts.join("\n\n") }))
+}
+
+fn area_of(pts: &[Vec2]) -> (f64, f64) {
+ let a = cadcraft_geom::shoelace(pts).abs();
+ let n = pts.len();
+ let per: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
+ (a, per)
+}
+
+fn run_area(s: &mut Session, p: &Value) -> Result {
+ let (a, per) = if let Some(pts) = points_param(p, "points") {
+ area_of(&pts)
+ } else {
+ let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
+ let d = s.doc()?;
+ let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?;
+ match &e.kind {
+ EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU),
+ EntityKind::LwPolyline(pl) => {
+ let g = Polyline { vertices: pl.vertices.clone(), closed: true };
+ (g.area().abs(), g.len())
+ }
+ EntityKind::Ellipse(el) => {
+ let a = el.major.xy().len();
+ let b = a * el.ratio;
+ (cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt()))
+ }
+ EntityKind::Hatch(hh) => hh.loops.iter().fold((0.0, 0.0), |acc, l| {
+ let g = Polyline { vertices: l.vertices.clone(), closed: true };
+ (acc.0 + g.area().abs(), acc.1 + g.len())
+ }),
+ _ => return Err(bad("area", "object has no area")),
+ }
+ };
+ Ok(json!({ "area": a, "perimeter": per, "message": format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) }))
+}
+
+fn run_time(s: &mut Session, _p: &Value) -> Result {
+ let rev = s.state()?.revision;
+ Ok(json!({ "message": format!("Drawing revision: {rev}\nEdits recorded: {}", s.state()?.undo.len()) }))
+}
+
+fn run_status(s: &mut Session, _p: &Value) -> Result {
+ let d = s.doc()?;
+ let ext = d.extents(&cadcraft_doc::Space::Model);
+ let msg = format!(
+ "{} objects in {}\nModel space limits are X: {} Y: {} (Off)\nModel space uses X: {} Y: {}\n X: {} Y: {}\nCurrent layer: {}\nCurrent color: {}\nCurrent linetype: {}",
+ d.entity_count(),
+ s.state()?.title,
+ fmt_d(s, d.header.point("LIMMIN").map(|p| p.x).unwrap_or(0.0)),
+ fmt_d(s, d.header.point("LIMMIN").map(|p| p.y).unwrap_or(0.0)),
+ fmt_d(s, ext.min.x),
+ fmt_d(s, ext.min.y),
+ fmt_d(s, ext.max.x),
+ fmt_d(s, ext.max.y),
+ d.header.str("CLAYER", "0"),
+ cadcraft_color::Color::from_aci(d.header.i64("CECOLOR", 256) as i16).name(),
+ d.header.str("CELTYPE", "ByLayer"),
+ );
+ Ok(json!({ "message": msg }))
+}
+
+fn entity_summary(e: &cadcraft_doc::Entity) -> Value {
+ json!({ "handle": e.handle.hex(), "type": e.kind.type_name(), "layer": e.common.layer, "color": e.common.color.name(), "geometry": e.kind })
+}
+
+fn run_inspect(s: &mut Session, p: &Value) -> Result {
+ let st = s.state()?;
+ let d = &st.doc;
+ let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
+ let space = st.space.clone();
+ let ext = d.extents(&space);
+ let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
+ if let Some(store) = d.space(&space) {
+ for e in store.iter() {
+ *counts.entry(e.kind.type_name()).or_default() += 1;
+ }
+ }
+ let mut out = json!({
+ "title": st.title,
+ "path": st.path,
+ "dirty": st.is_dirty(),
+ "space": match &space { cadcraft_doc::Space::Model => "Model".to_string(), cadcraft_doc::Space::Paper(n) => n.clone() },
+ "entityCount": d.entity_count(),
+ "counts": counts,
+ "extents": if ext.is_empty() { Value::Null } else { json!([[ext.min.x, ext.min.y], [ext.max.x, ext.max.y]]) },
+ "layers": d.layers.iter().map(|l| json!({"name": l.name, "on": l.on, "frozen": l.frozen, "locked": l.locked, "color": l.color.name(), "linetype": l.linetype})).collect::>(),
+ "currentLayer": d.header.str("CLAYER", "0"),
+ "linetypes": d.linetypes.iter().map(|l| l.name.clone()).collect::