diff --git a/.gitignore b/.gitignore index 0807ae54..c2708975 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *~ -plan/ -plans/ +/target +/plan/ +/plans/ +/dist/ +/log/ +/temp/ +.DS_Store +*.swp +/apps/cadcraft-web/dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3fb0979f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# CADCraft — instructions for agents + +CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../printcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). + +Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. + +## Start every session here +1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name). +2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons). +3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask unless it lists the decision as the owner's. + +`plan/` is gitignored (local only). + +## Never crash +People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). +- **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. +- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. +- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). +- **Last-resort guard:** `Session::execute` and interactive command input run under `catch_unwind`; an escaped panic restores the drawing and reports an error. +- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). +- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. + +## Non-negotiables +- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources. +- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`), never a `Cargo.toml` dependency. Code and tests must work without it. +- **Shared test corpora** live in separate repos (`storytold/-corpus`); never commit large binary fixtures here. +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands. +- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. +- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** +- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. +- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. + +## Running and looking at the app +- `cargo run --release -p cadcraft -- --sample --control PORT` (sample drawing + control channel). Pick a free port; other crafting apps use control ports too. +- Drive it with JSON lines on `127.0.0.1:PORT`: + - `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` — type at the command line exactly like a user (prompts, keywords, `@dx,dy`, `@d`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. +- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. +- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). +- **Last-resort guard:** `Session::execute` and interactive command input run under `catch_unwind`; an escaped panic restores the drawing and reports an error. +- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). +- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. + +## Non-negotiables +- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources. +- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`), never a `Cargo.toml` dependency. Code and tests must work without it. +- **Shared test corpora** live in separate repos (`storytold/-corpus`); never commit large binary fixtures here. +- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands. +- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. +- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** +- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. +- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. + +## Running and looking at the app +- `cargo run --release -p cadcraft -- --sample --control PORT` (sample drawing + control channel). Pick a free port; other crafting apps use control ports too. +- Drive it with JSON lines on `127.0.0.1:PORT`: + - `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` — type at the command line exactly like a user (prompts, keywords, `@dx,dy`, `@d + + + + ArtCraft + + +

-By ArtCraft +

CADCraft

+ +

+ Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust. +

+ +

+ 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. +

+ +

+ Written in Rust + Runs on macOS, Windows, Linux, FreeBSD and the web + License: MIT OR Apache-2.0 + Agent-drivable over MCP + Status: early development +

+ +

+ Join the ArtCraft community on Discord +

+ +

+ CADCraft on getartcraft.com · + ArtCraft · + All Crafting Apps +

+ +
+ +

+ CADCraft with a mechanical drawing of a mounting bracket open: a front view with bolt holes, red centre lines, a yellow hidden circle and cyan dimensions, a hatched section view, notes and a title block; the Tool Sets palette is on the left, Layers and Properties on the right and the command line at the bottom is asking for the next point of a LINE +
Mounting bracket: a two-view part drawing with dimensions, centre lines, hidden lines and an ANSI31 section hatch — drawn, dimensioned and rendered by CADCraft. +

+ +> [!NOTE] +> **ArtCraft is a community of artists from all walks of life.** Painters, photographers, +> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil +> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).** + +

+ Why CADCraft · + What works today · + Quick start · + Agents, MCP and the CLI · + Architecture · + Roadmap · + The Crafting Apps · + License and credits +

+ +## Why CADCraft + +- **The workflow you know.** Type `L`, click two points, type `@5<45`, press Enter. The command + line, prompts with clickable `[Keywords]`, AutoComplete, object snaps, polar tracking, ortho, + direct distance entry, window and crossing selection, grips, and right-click-to-repeat behave + the way decades of drafting habit expect. +- **Open files.** DXF is read and written natively (ASCII and binary, R12 through 2018); DWG is + on the roadmap. Export to SVG and PNG today. +- **Fast and native.** Pure Rust and egui, no Electron, no web view. One binary on macOS + (universal), Windows, Linux and FreeBSD, plus a WebAssembly build for the browser. +- **Built for agents.** Every menu item, tool and prompt is a command. Agents can type at the + command line exactly like a person, call any command with JSON, inspect the drawing to verify + their work, and render it — over MCP, a JSON control channel, or the CLI. +- **Free.** MIT OR Apache-2.0, with no account and no subscription. + +## What works today + +CADCraft is in early, fast development. Honest status (see [ROADMAP.md](ROADMAP.md) for parity +numbers): + +| Area | Status | +|---|---| +| Drawing area | Model space with adaptive grid, axes, pan/zoom (wheel, middle-drag, pinch), crosshair cursor with pickbox, UCS icon, ViewCube, viewport label | +| Command line | Prompts with keywords, history, AutoComplete, aliases, `@dx,dy`, `@d | **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. + +
+ +

+ Join the ArtCraft community on Discord +

+ +

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. + +

+ ArtCraft
+ 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::>(), + "textStyles": d.text_styles.iter().map(|l| l.name.clone()).collect::>(), + "dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::>(), + "blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::>(), + "layouts": d.layouts.iter().map(|l| l.name.clone()).collect::>(), + "selection": st.selection.iter().map(|h| h.hex()).collect::>(), + "undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::>(), + "view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height }, + }); + if bool_or(p, "entities", true) + && let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space)) + { + o.insert("entities".into(), Value::Array(store.iter().take(limit).map(|e| entity_summary(e)).collect())); + } + Ok(out) +} + +fn run_entities(s: &mut Session, p: &Value) -> Result { + let st = s.state()?; + let d = &st.doc; + let ty = str_param(p, "type").map(str::to_ascii_lowercase); + let layer = str_param(p, "layer").map(str::to_ascii_lowercase); + let window = points_param(p, "window").and_then(|w| Some(cadcraft_geom::Bounds2::new(*w.first()?, *w.get(1)?))); + let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize; + let offset = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; + let Some(store) = d.space(&st.space) else { return Ok(json!({ "entities": [] })) }; + let matches: Vec = store + .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| window.is_none_or(|w| w.intersects(&cadcraft_doc::entity_bounds(d, e, 0)))) + .skip(offset) + .take(limit) + .map(|e| entity_summary(e)) + .collect(); + Ok(json!({ "count": matches.len(), "entities": matches })) +} + +fn run_count(s: &mut Session, p: &Value) -> Result { + let d = s.doc()?; + let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for e in d.model.iter() { + if let EntityKind::Insert(i) = &e.kind { + *counts.entry(i.block.clone()).or_default() += 1; + } + } + if let Some(b) = str_param(p, "block") { + return Ok(json!({ "block": b, "count": counts.get(b).copied().unwrap_or(0) })); + } + Ok(json!({ "blocks": counts })) +} + +#[derive(Default)] +struct DistM { + first: Option, +} + +impl Interactive for DistM { + fn name(&self) -> &'static str { + "DIST" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.first { + None => Prompt::new("Specify first point", Accept::POINT), + Some(a) => Prompt::new("Specify second point", Accept::POINT).kw(&["Multiple points"]).base(a), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.first, i) { + (None, Input::Point(p)) => { + self.first = Some(p); + Ok(Step::Continue) + } + (Some(a), Input::Point(b)) => { + let t = dist_text(s, a, b); + for l in t.lines() { + s.echo(l.to_string()); + } + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default() + } +} + +struct IdM; + +impl Interactive for IdM { + fn name(&self) -> &'static str { + "ID" + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Specify point", Accept::POINT) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Input::Point(p) = i { + let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0)); + s.echo(m); + } + Ok(Step::Done) + } +} + +#[derive(Default)] +struct ListM { + sel: super::machines::SelectPhase, +} + +impl Interactive for ListM { + fn name(&self) -> &'static str { + "LIST" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = super::machines::SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + for h in hs { + let t = list_text(s, h); + for l in t.lines() { + s.echo(l.to_string()); + } + } + return Ok(Step::Done); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + self.sel.prompt() + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.sel.feed(s, &i)? { + super::machines::SelOutcome::More => Ok(Step::Continue), + super::machines::SelOutcome::Empty => Ok(Step::Done), + super::machines::SelOutcome::Done(hs) => { + for h in hs { + let t = list_text(s, h); + for l in t.lines() { + s.echo(l.to_string()); + } + } + Ok(Step::Done) + } + } + } +} + +#[derive(Default)] +struct AreaM { + pts: Vec, +} + +impl Interactive for AreaM { + fn name(&self) -> &'static str { + "AREA" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.pts.len() { + 0 => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object"), + 1 => Prompt::new("Specify next point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify next point", Accept::POINT) + .kw(&["Arc", "Length", "Undo", "Total"]) + .default("Total") + .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::Enter | Input::Keyword(_) if self.pts.len() >= 3 => { + let (a, per) = area_of(&self.pts); + let m = format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)); + s.echo(m); + Ok(Step::Done) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() { + return Vec::new(); + } + let mut v: Vec = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); + v.push(cadcraft_geom::PolyVertex::new(c)); + vec![super::helpers::lwpoly(v, true)] + } +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs new file mode 100644 index 00000000..3d03baea --- /dev/null +++ b/crates/engine/src/cmd/layer.rs @@ -0,0 +1,315 @@ +//! Layers: LAYER (programmatic sub-ids), layer tools (LAYMCUR, LAYISO, LAYOFF, LAYFRZ…). + +use cadcraft_color::Color; +use cadcraft_doc::{Layer, Lineweight}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("layer", "Layers", run_list).menu(&["Format", "Layers"]).alias(&["la", "layers"]).noundo(), + CommandSpec::new("layer.new", "New Layer", run_new).params("{name, color?, linetype?, lineweight? (mm), current?: bool}"), + CommandSpec::new("layer.set", "Set Layer Properties", run_set) + .params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newName?}"), + CommandSpec::new("layer.current", "Make Current", run_current) + .menu(&["Format", "Layer Tools", "Make Current"]) + .alias(&["clayer"]) + .params("{name}"), + CommandSpec::new("layer.delete", "Delete Layer", run_delete).params("{name}"), + CommandSpec::new("laymcur", "Make Object's Layer Current", run_laymcur).params("{handles?}"), + CommandSpec::new("laymch", "Layer Match", run_laymch).menu(&["Format", "Layer Tools", "Layer Match"]).params("{handles?, layer}"), + CommandSpec::new("laycur", "Change to Current Layer", run_laycur) + .menu(&["Format", "Layer Tools", "Change to Current Layer"]) + .params("{handles?}"), + CommandSpec::new("layiso", "Isolate Layer", run_layiso).menu(&["Format", "Layer Tools", "Isolate Layer"]).params("{handles?}"), + CommandSpec::new("layuniso", "Unisolate Layer", run_layuniso).menu(&["Format", "Layer Tools", "Unisolate Layer"]), + CommandSpec::new("layfrz", "Freeze Layer", |s, p| set_obj_layers(s, p, |l| l.frozen = true)) + .menu(&["Format", "Layer Tools", "Freeze Layer"]) + .params("{handles?}"), + CommandSpec::new("layoff", "Layer Off", |s, p| set_obj_layers(s, p, |l| l.on = false)) + .menu(&["Format", "Layer Tools", "Layer Off"]) + .params("{handles?}"), + CommandSpec::new("laylck", "Lock Layer", |s, p| set_obj_layers(s, p, |l| l.locked = true)) + .menu(&["Format", "Layer Tools", "Lock Layer"]) + .params("{handles?}"), + CommandSpec::new("layulk", "Unlock Layer", |s, p| set_obj_layers(s, p, |l| l.locked = false)) + .menu(&["Format", "Layer Tools", "Unlock Layer"]) + .params("{handles?}"), + CommandSpec::new("layon", "Turn All Layers On", |s, _| all_layers(s, |l| l.on = true)), + CommandSpec::new("laythw", "Thaw All Layers", |s, _| all_layers(s, |l| l.frozen = false)), + CommandSpec::new("layerp", "Previous Layer", run_layerp).menu(&["Format", "Layer Tools", "Previous Layer"]), + CommandSpec::new("layerstate.save", "Save Layer State", run_state_save).menu(&["Format", "Layer States Manager..."]).params("{name}"), + CommandSpec::new("layerstate.restore", "Restore Layer State", run_state_restore).params("{name}"), + ] +} + +fn layer_json(l: &Layer, current: bool) -> Value { + json!({ + "name": l.name, "on": l.on, "frozen": l.frozen, "locked": l.locked, "plot": l.plot, + "color": l.color.name(), "colorRgb": l.color.resolve(Color::Index(7), Color::Index(7)).hex(), + "linetype": l.linetype, "lineweight": l.lineweight.name(), "transparency": l.transparency, + "description": l.description, "current": current, + }) +} + +fn run_list(s: &mut Session, _p: &Value) -> Result { + let d = s.doc()?; + let cur = d.header.str("CLAYER", "0"); + Ok(json!({ "layers": d.layers.iter().map(|l| layer_json(l, l.name.eq_ignore_ascii_case(&cur))).collect::>() })) +} + +fn parse_lw(v: &Value) -> Option { + if let Some(f) = v.as_f64() { + return Some(Lineweight::Mm100((f * 100.0).round().clamp(0.0, 211.0) as u16)); + } + match v.as_str()?.to_ascii_lowercase().as_str() { + "bylayer" => Some(Lineweight::ByLayer), + "byblock" => Some(Lineweight::ByBlock), + "default" => Some(Lineweight::Default), + s => s.trim_end_matches("mm").trim().parse::().ok().map(|f| Lineweight::Mm100((f * 100.0).round().clamp(0.0, 211.0) as u16)), + } +} + +fn apply(l: &mut Layer, p: &Value) -> Result<()> { + if let Some(v) = p.get("on").and_then(Value::as_bool) { + l.on = v; + } + if let Some(v) = p.get("frozen").and_then(Value::as_bool) { + l.frozen = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + l.locked = v; + } + if let Some(v) = p.get("plot").and_then(Value::as_bool) { + l.plot = v; + } + if let Some(c) = p.get("color") { + let c = c + .as_str() + .and_then(Color::parse) + .or_else(|| c.as_u64().and_then(|i| u8::try_from(i).ok()).filter(|i| *i > 0).map(Color::Index)) + .ok_or_else(|| bad("layer", "bad colour"))?; + if matches!(c, Color::ByLayer | Color::ByBlock) { + return Err(bad("layer", "a layer colour must be an index or true colour")); + } + l.color = c; + } + if let Some(lt) = str_param(p, "linetype") { + l.linetype = lt.to_string(); + } + if let Some(lw) = p.get("lineweight") { + l.lineweight = parse_lw(lw).ok_or_else(|| bad("layer", "bad lineweight"))?; + } + if let Some(t) = p.get("transparency").and_then(Value::as_u64) { + l.transparency = t.min(90) as u8; + } + if let Some(d) = str_param(p, "description") { + l.description = d.to_string(); + } + Ok(()) +} + +fn valid_name(n: &str) -> bool { + !n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) +} + +fn run_new(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layer.new", "`name` is required"))?.to_string(); + if !valid_name(&name) { + return Err(bad("layer.new", "invalid layer name")); + } + let d = s.doc_mut()?; + if d.layer(&name).is_some() { + return Err(bad("layer.new", format!("layer `{name}` already exists"))); + } + let mut l = Layer::new(&name); + apply(&mut l, p)?; + d.layers.push(l); + if bool_or(p, "current", false) { + d.header.set_str("CLAYER", &name); + } + ok() +} + +fn run_set(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layer.set", "`name` is required"))?.to_string(); + let new_name = str_param(p, "newName").map(str::to_string); + let d = s.doc_mut()?; + let cur = d.header.str("CLAYER", "0"); + let l = d.layer_mut(&name).ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?; + apply(l, p)?; + if p.get("frozen").and_then(Value::as_bool) == Some(true) && l.name.eq_ignore_ascii_case(&cur) { + l.frozen = false; + return Err(bad("layer.set", "cannot freeze the current layer")); + } + if let Some(nn) = new_name { + if l.name == "0" || l.name.eq_ignore_ascii_case("Defpoints") { + return Err(bad("layer.set", "cannot rename layer 0 or Defpoints")); + } + if !valid_name(&nn) { + return Err(bad("layer.set", "invalid layer name")); + } + let old = l.name.clone(); + l.name = nn.clone(); + // Re-point entities. + let hs: Vec<_> = d.model.iter().filter(|e| e.common.layer.eq_ignore_ascii_case(&old)).map(|e| e.handle).collect(); + for h in hs { + d.model.modify(h, |e| e.common.layer = nn.clone()); + } + if cur.eq_ignore_ascii_case(&old) { + d.header.set_str("CLAYER", &nn); + } + } + ok() +} + +fn run_current(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layer.current", "`name` is required"))?.to_string(); + let d = s.doc_mut()?; + let l = d.layer_mut(&name).ok_or_else(|| bad("layer.current", format!("no layer `{name}`")))?; + l.frozen = false; + let n = l.name.clone(); + prev_push(d); + d.header.set_str("CLAYER", &n); + ok() +} + +fn prev_push(d: &mut cadcraft_doc::Drawing) { + let snap = serde_json::to_string(&d.layers).unwrap_or_default(); + d.header.set_str("CADCRAFT_LAYERP", &snap); +} + +fn run_layerp(s: &mut Session, _p: &Value) -> Result { + let d = s.doc_mut()?; + let snap = d.header.str("CADCRAFT_LAYERP", ""); + let layers: Vec = serde_json::from_str(&snap).map_err(|_| bad("layerp", "No previous layer state."))?; + d.layers = layers; + Ok(json!({"message": "Restored previous layer states."})) +} + +fn run_delete(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layer.delete", "`name` is required"))?.to_string(); + let d = s.doc_mut()?; + if name == "0" || name.eq_ignore_ascii_case("Defpoints") || d.header.str("CLAYER", "0").eq_ignore_ascii_case(&name) { + return Err(bad("layer.delete", "cannot delete layer 0, Defpoints or the current layer")); + } + let used = d.model.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name)) + || d.layouts.iter().any(|l| l.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name))); + if used { + return Err(bad("layer.delete", "layer has objects on it")); + } + d.layers.retain(|l| !l.name.eq_ignore_ascii_case(&name)); + ok() +} + +fn obj_layers(s: &Session, p: &Value) -> Result> { + let hs = targets(s, p)?; + let d = s.doc()?; + let mut v: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| e.common.layer.clone())).collect(); + v.sort(); + v.dedup(); + Ok(v) +} + +fn run_laymcur(s: &mut Session, p: &Value) -> Result { + let ls = obj_layers(s, p)?; + let l = ls.first().cloned().ok_or_else(|| bad("laymcur", "select an object"))?; + s.doc_mut()?.header.set_str("CLAYER", &l); + Ok(json!({ "message": format!("{l} is now the current layer.") })) +} + +fn run_laymch(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let layer = str_param(p, "layer").ok_or_else(|| bad("laymch", "`layer` is required"))?.to_string(); + let d = s.doc_mut()?; + d.ensure_layer(&layer); + for h in &hs { + d.modify_entity(*h, |e| e.common.layer = layer.clone())?; + } + Ok(json!({ "changed": hs.len() })) +} + +fn run_laycur(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let d = s.doc_mut()?; + let cur = d.header.str("CLAYER", "0"); + for h in &hs { + d.modify_entity(*h, |e| e.common.layer = cur.clone())?; + } + Ok(json!({ "changed": hs.len() })) +} + +fn run_layiso(s: &mut Session, p: &Value) -> Result { + let keep = obj_layers(s, p)?; + if keep.is_empty() { + return Err(bad("layiso", "select objects on the layers to isolate")); + } + let d = s.doc_mut()?; + prev_push(d); + d.header.set_str("CADCRAFT_LAYISO", &serde_json::to_string(&d.layers).unwrap_or_default()); + for l in &mut d.layers { + if !keep.iter().any(|k| k.eq_ignore_ascii_case(&l.name)) { + l.on = false; + } + } + if let Some(k) = keep.first() { + d.header.set_str("CLAYER", k); + } + Ok(json!({ "message": format!("Layer(s) {} have been isolated.", keep.join(", ")) })) +} + +fn run_layuniso(s: &mut Session, _p: &Value) -> Result { + let d = s.doc_mut()?; + let snap = d.header.str("CADCRAFT_LAYISO", ""); + let layers: Vec = serde_json::from_str(&snap).map_err(|_| bad("layuniso", "No isolated layers."))?; + d.layers = layers; + ok() +} + +fn set_obj_layers(s: &mut Session, p: &Value, f: fn(&mut Layer)) -> Result { + let ls = obj_layers(s, p)?; + let d = s.doc_mut()?; + let cur = d.header.str("CLAYER", "0"); + prev_push(d); + for n in &ls { + if let Some(l) = d.layer_mut(n) { + let was_frozen = l.frozen; + f(l); + if l.frozen && !was_frozen && l.name.eq_ignore_ascii_case(&cur) { + l.frozen = false; + } + } + } + s.set_selection(Vec::new()); + Ok(json!({ "layers": ls })) +} + +fn all_layers(s: &mut Session, f: fn(&mut Layer)) -> Result { + let d = s.doc_mut()?; + prev_push(d); + d.layers.iter_mut().for_each(f); + ok() +} + +fn run_state_save(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layerstate.save", "`name` is required"))?.to_string(); + let d = s.doc_mut()?; + let layers = d.layers.clone(); + d.layer_states.retain(|st| st.name != name); + d.layer_states.push(cadcraft_doc::LayerState { name, layers }); + ok() +} + +fn run_state_restore(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?; + let d = s.doc_mut()?; + let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?; + for saved in st.layers { + if let Some(l) = d.layer_mut(&saved.name) { + *l = saved; + } + } + ok() +} diff --git a/crates/engine/src/cmd/machines.rs b/crates/engine/src/cmd/machines.rs new file mode 100644 index 00000000..94c26a2c --- /dev/null +++ b/crates/engine/src/cmd/machines.rs @@ -0,0 +1,101 @@ +//! Building blocks for interactive commands: object selection and simple point sequences. + +use cadcraft_doc::Handle; + +use crate::{Accept, Input, Prompt, Result, Session}; + +/// A "Select objects:" phase. Uses the pickfirst selection when there is one. +#[derive(Clone, Debug, Default)] +pub struct SelectPhase { + pub picked: Vec, + pub done: bool, + pub single: bool, + pub removing: bool, +} + +pub enum SelOutcome { + /// Keep prompting. + More, + /// Selection finished with these objects. + Done(Vec), + /// Selection finished empty: the command should end. + Empty, +} + +impl SelectPhase { + /// Start with the pickfirst selection, if any (then the phase is already done). + pub fn begin(s: &mut Session) -> Self { + let pre = s.selection(); + if !pre.is_empty() && s.settings.pickfirst { + s.remember_selection(&pre); + SelectPhase { picked: pre, done: true, single: false, removing: false } + } else { + SelectPhase::default() + } + } + pub fn single() -> Self { + SelectPhase { single: true, ..Default::default() } + } + pub fn prompt(&self) -> Prompt { + let msg = if self.removing { + "Remove objects" + } else if self.single { + "Select object" + } else { + "Select objects" + }; + Prompt::new(msg, Accept::SELECT) + } + pub fn feed(&mut self, s: &mut Session, i: &Input) -> Result { + match i { + Input::Pick(hs) => { + let before = self.picked.len(); + if self.removing { + self.picked.retain(|h| !hs.contains(h)); + } else { + for h in hs { + if !self.picked.contains(h) { + self.picked.push(*h); + } + } + } + let n = hs.len(); + let _ = before; + if n > 0 { + s.echo(format!("{n} found, {} total", self.picked.len())); + } else { + s.echo("0 found"); + } + s.set_selection(self.picked.clone()); + if self.single && !self.picked.is_empty() { + return Ok(self.finish(s)); + } + Ok(SelOutcome::More) + } + Input::Text(t) | Input::Keyword(t) => { + match t.trim().to_ascii_lowercase().as_str() { + "r" | "remove" => self.removing = true, + "a" | "add" => self.removing = false, + _ => s.echo("*Invalid selection*"), + } + Ok(SelOutcome::More) + } + Input::Enter => Ok(self.finish(s)), + _ => Ok(SelOutcome::More), + } + } + fn finish(&mut self, s: &mut Session) -> SelOutcome { + self.done = true; + if self.picked.is_empty() { + SelOutcome::Empty + } else { + s.remember_selection(&self.picked); + SelOutcome::Done(self.picked.clone()) + } + } +} + +/// Parse a number from text input. +pub fn number(t: &str) -> Option { + crate::units::parse_distance(t) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 00000000..fba26a89 --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,219 @@ +//! The command registry. Ids are AutoCAD command names in lower case; menu paths follow +//! AutoCAD's menu bar so the catalog doubles as the parity metric. + +mod draw; +mod edit; +pub mod file; +mod inquiry; +mod layer; +mod modify; +mod props; +mod settings; +mod view; + +pub mod helpers; +pub mod machines; + +use cadcraft_doc::Handle; +use cadcraft_geom::Vec2; +use serde::Serialize; +use serde_json::Value; + +use crate::{EngineError, Interactive, Result, Session}; + +pub type Run = fn(&mut Session, &Value) -> Result; +pub type Enabled = fn(&Session) -> std::result::Result<(), String>; +pub type Factory = fn(&Session) -> Result>; + +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Menu placement, e.g. `["Draw", "Circle", "Center, Radius"]`. Empty = not in menus. + pub menu: &'static [&'static str], + pub shortcut: Option<&'static str>, + pub aliases: &'static [&'static str], + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + pub undoable: bool, + pub interactive: Option, + /// Can run inside another command with a leading apostrophe. + pub transparent: bool, +} + +impl CommandSpec { + pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self { + CommandSpec { + id, + label, + menu: &[], + shortcut: None, + aliases: &[], + params: "", + enabled: has_doc, + run, + undoable: true, + interactive: None, + transparent: false, + } + } + pub const fn menu(mut self, m: &'static [&'static str]) -> Self { + self.menu = m; + self + } + pub const fn key(mut self, k: &'static str) -> Self { + self.shortcut = Some(k); + self + } + pub const fn alias(mut self, a: &'static [&'static str]) -> Self { + self.aliases = a; + self + } + pub const fn params(mut self, p: &'static str) -> Self { + self.params = p; + self + } + pub const fn enabled(mut self, e: Enabled) -> Self { + self.enabled = e; + self + } + pub const fn noundo(mut self) -> Self { + self.undoable = false; + self + } + pub const fn interactive(mut self, f: Factory) -> Self { + self.interactive = Some(f); + self + } + pub const fn transparent(mut self) -> Self { + self.transparent = true; + self + } + pub fn info(&self, s: &Session) -> CommandInfo { + let e = (self.enabled)(s); + CommandInfo { + id: self.id, + label: self.label, + menu: self.menu.to_vec(), + shortcut: self.shortcut, + aliases: self.aliases.to_vec(), + params: self.params, + interactive: self.interactive.is_some(), + enabled: e.is_ok(), + disabled_reason: e.err(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandInfo { + pub id: &'static str, + pub label: &'static str, + pub menu: Vec<&'static str>, + pub shortcut: Option<&'static str>, + pub aliases: Vec<&'static str>, + pub params: &'static str, + pub interactive: bool, + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +pub fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} +pub fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.state().map(|_| ()).map_err(|_| "no drawing open".into()) +} +pub fn has_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.selection().is_empty() { Err("nothing selected".into()) } else { Ok(()) } +} + +pub fn command_specs() -> &'static [CommandSpec] { + static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SPECS.get_or_init(|| { + let mut v = Vec::new(); + v.extend(file::specs()); + v.extend(edit::specs()); + v.extend(view::specs()); + v.extend(draw::specs()); + v.extend(modify::specs()); + v.extend(layer::specs()); + v.extend(props::specs()); + v.extend(inquiry::specs()); + v.extend(settings::specs()); + v + }) +} + +pub fn find_command(id: &str) -> Option<&'static CommandSpec> { + let l = id.to_ascii_lowercase(); + command_specs().iter().find(|c| c.id == l) +} + +/// Resolve a typed name or alias (AutoCAD-style short names such as `L`, `C`, `PL`) to an id. +pub fn resolve_alias(name: &str) -> String { + let l = name.to_ascii_lowercase(); + if find_command(&l).is_some() { + return l; + } + command_specs().iter().find(|c| c.aliases.iter().any(|a| a.eq_ignore_ascii_case(&l))).map(|c| c.id.to_string()).unwrap_or(l) +} + +// ---------- param helpers ---------- + +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} +pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { + p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(default) +} +pub(crate) fn f64_req(cmd: &str, p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` (number) is required"))) +} +pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { + p.get(key).and_then(Value::as_bool).unwrap_or(default) +} +pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + p.get(key).and_then(Value::as_str) +} +pub fn point_value(v: &Value) -> Option { + if let Some(a) = v.as_array() { + let x = a.first()?.as_f64()?; + let y = a.get(1)?.as_f64()?; + let p = Vec2::new(x, y); + return p.is_finite().then_some(p); + } + if let Some(s) = v.as_str() { + return crate::prompt::parse_point(s, Vec2::ZERO); + } + let x = v.get("x")?.as_f64()?; + let y = v.get("y")?.as_f64()?; + let p = Vec2::new(x, y); + p.is_finite().then_some(p) +} +pub(crate) fn point_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(point_value) +} +pub(crate) fn point_req(cmd: &str, p: &Value, key: &str) -> Result { + point_param(p, key).ok_or_else(|| bad(cmd, format!("`{key}` ([x, y]) is required"))) +} +pub(crate) fn points_param(p: &Value, key: &str) -> Option> { + p.get(key)?.as_array()?.iter().map(point_value).collect() +} +/// Handles from `handles` (hex strings or numbers) or the current selection. +pub(crate) fn targets(s: &Session, p: &Value) -> Result> { + if let Some(a) = p.get("handles").and_then(Value::as_array) { + let hs: Vec = a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect(); + return Ok(hs); + } + if let Some(h) = p.get("handle").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) { + return Ok(vec![h]); + } + Ok(s.selection()) +} +pub(crate) fn ok() -> Result { + Ok(Value::Null) +} diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs new file mode 100644 index 00000000..c69c9694 --- /dev/null +++ b/crates/engine/src/cmd/modify.rs @@ -0,0 +1,1887 @@ +//! Modify commands: ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, OFFSET, TRIM, EXTEND, FILLET, +//! CHAMFER, EXPLODE, STRETCH, ARRAY, DRAWORDER, BREAK, JOIN. + +use cadcraft_doc::{Entity, EntityKind, Handle, Prim}; +use cadcraft_geom::{ + Arc, Circle, Line, Mat3, PolyVertex, Polyline, Segment, TAU, Vec2, angle_in_sweep, ccw_sweep, intersect_ext, line_line_infinite, norm_angle, +}; +use serde_json::{Value, json}; + +use super::helpers::*; +use super::machines::{SelOutcome, SelectPhase, number}; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("erase", "Erase", run_erase) + .menu(&["Modify", "Erase"]) + .alias(&["e", "delete"]) + .params("{handles?: [hex]} (default: selection)") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))), + CommandSpec::new("move", "Move", run_move) + .menu(&["Modify", "Move"]) + .alias(&["m"]) + .params("{handles?, from?: [x,y], to?: [x,y] | delta: [dx,dy]}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Move)))), + CommandSpec::new("copy", "Copy", run_copy) + .menu(&["Modify", "Copy"]) + .alias(&["co", "cp"]) + .params("{handles?, delta: [dx,dy] | from,to, count?: n}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Copy)))), + CommandSpec::new("rotate", "Rotate", run_rotate) + .menu(&["Modify", "Rotate"]) + .alias(&["ro"]) + .params("{handles?, base: [x,y], angle (degrees), copy?: bool}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Rotate)))), + CommandSpec::new("scale", "Scale", run_scale) + .menu(&["Modify", "Scale"]) + .alias(&["sc"]) + .params("{handles?, base: [x,y], factor, copy?: bool}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))), + CommandSpec::new("mirror", "Mirror", run_mirror) + .menu(&["Modify", "Mirror"]) + .alias(&["mi"]) + .params("{handles?, p1, p2, erase?: bool}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Mirror)))), + CommandSpec::new("stretch", "Stretch", run_stretch) + .menu(&["Modify", "Stretch"]) + .alias(&["s"]) + .params("{window: [[x,y],[x,y]], delta: [dx,dy]}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Stretch)))), + CommandSpec::new("offset", "Offset", run_offset) + .menu(&["Modify", "Offset"]) + .alias(&["o"]) + .params("{handle, distance, side: [x,y]}") + .interactive(|s| Ok(Box::new(OffsetM::new(s)))), + CommandSpec::new("trim", "Trim", run_trim) + .menu(&["Modify", "Trim"]) + .alias(&["tr"]) + .params("{handle, pick: [x,y], edges?: [hex]}") + .interactive(|_| Ok(Box::new(TrimM { extend: false }))), + CommandSpec::new("extend", "Extend", run_extend) + .menu(&["Modify", "Extend"]) + .alias(&["ex"]) + .params("{handle, pick: [x,y], edges?: [hex]}") + .interactive(|_| Ok(Box::new(TrimM { extend: true }))), + CommandSpec::new("fillet", "Fillet", run_fillet) + .menu(&["Modify", "Fillet"]) + .alias(&["f"]) + .params("{h1, p1, h2, p2, radius?}") + .interactive(|s| Ok(Box::new(FilletM::new(s, false)))), + CommandSpec::new("chamfer", "Chamfer", run_chamfer) + .menu(&["Modify", "Chamfer"]) + .alias(&["cha"]) + .params("{h1, p1, h2, p2, d1?, d2?}") + .interactive(|s| Ok(Box::new(FilletM::new(s, true)))), + CommandSpec::new("explode", "Explode", run_explode) + .menu(&["Modify", "Explode"]) + .alias(&["x"]) + .params("{handles?}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Explode)))), + CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect) + .menu(&["Modify", "Array", "Rectangular Array"]) + .params("{handles?, rows, cols, rowSpacing, colSpacing}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::ArrayRect)))), + CommandSpec::new("arraypolar", "Polar Array", run_arraypolar) + .menu(&["Modify", "Array", "Polar Array"]) + .params("{handles?, center, count, angle? (degrees, default 360), rotate?: bool}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::ArrayPolar)))), + CommandSpec::new("draworder.front", "Bring to Front", run_front).menu(&["Tools", "Draw Order", "Bring to Front"]).params("{handles?}"), + CommandSpec::new("draworder.back", "Send to Back", run_back).menu(&["Tools", "Draw Order", "Send to Back"]).params("{handles?}"), + CommandSpec::new("break", "Break", run_break) + .menu(&["Modify", "Break"]) + .alias(&["br"]) + .params("{handle, p1, p2}") + .interactive(|_| Ok(Box::new(BreakM::default()))), + CommandSpec::new("breakatpoint", "Break At Point", run_breakat).menu(&["Modify", "Break At Point"]).params("{handle, at}"), + CommandSpec::new("join", "Join", run_join) + .menu(&["Modify", "Join"]) + .alias(&["j"]) + .params("{handles?}") + .interactive(|_| Ok(Box::new(SelectThen::new(Op::Join)))), + CommandSpec::new("overkill", "Delete Duplicate Objects", run_overkill).menu(&["Modify", "Delete Duplicate Objects"]).params("{handles?}"), + ] +} + +// ---------------- core operations ---------------- + +pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { + let d = s.doc_mut()?; + let mut out = Vec::new(); + for h in hs { + let Some(sp) = d.space_of(*h) else { continue }; + if copy { + let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; + let nh = d.new_handle(); + let mut ne = Entity { handle: nh, ..e }; + ne.kind.transform(m); + if let Some(st) = d.space_mut(&sp) { + st.push(ne); + out.push(nh); + } + } else { + let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); + if locked { + continue; + } + d.modify_entity(*h, |e| e.kind.transform(m))?; + out.push(*h); + } + } + Ok(out) +} + +fn erase(s: &mut Session, hs: &[Handle]) -> Result { + let d = s.doc_mut()?; + let mut n = 0; + for h in hs { + let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); + if !locked && d.remove_entity(*h).is_some() { + n += 1; + } + } + s.set_selection(Vec::new()); + Ok(n) +} + +fn run_erase(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = erase(s, &hs)?; + Ok(json!({ "erased": n })) +} + +fn delta_of(cmd: &str, p: &Value) -> Result { + if let Some(d) = point_param(p, "delta") { + return Ok(d); + } + let a = point_req(cmd, p, "from")?; + let b = point_req(cmd, p, "to")?; + Ok(b - a) +} + +fn run_move(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let d = delta_of("move", p)?; + let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; + Ok(json!({ "moved": moved.len() })) +} + +fn run_copy(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let d = delta_of("copy", p)?; + let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000); + let mut out = Vec::new(); + for k in 1..=n { + out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex())); + } + Ok(json!({ "handles": out })) +} + +fn run_rotate(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let base = point_req("rotate", p, "base")?; + let a = f64_req("rotate", p, "angle")?.to_radians(); + let r = transform_entities(s, &hs, &Mat3::rotate_about(base, a), bool_or(p, "copy", false))?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_scale(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let base = point_req("scale", p, "base")?; + let f = f64_req("scale", p, "factor")?; + if f <= 0.0 { + return Err(bad("scale", "factor must be positive")); + } + let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_mirror(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let a = point_req("mirror", p, "p1")?; + let b = point_req("mirror", p, "p2")?; + if a.near(b, 1e-12) { + return Err(bad("mirror", "mirror line points coincide")); + } + let erase_src = bool_or(p, "erase", false); + let r = transform_entities(s, &hs, &Mat3::mirror(a, b), !erase_src)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +/// Move the vertices of `e` that lie inside `bx` by `d` (STRETCH). +fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { + let mv = |p: &mut cadcraft_geom::Vec3| { + if bx.contains(p.xy()) { + p.x += d.x; + p.y += d.y; + } + }; + match &mut e.kind { + EntityKind::Line(l) => { + mv(&mut l.a); + mv(&mut l.b); + } + EntityKind::LwPolyline(pl) => { + for v in &mut pl.vertices { + if bx.contains(v.p) { + v.p += d; + } + } + } + EntityKind::Arc(a) => { + // Arcs keep their shape: move when the centre is inside. + if bx.contains(a.center.xy()) { + a.center.x += d.x; + a.center.y += d.y; + } + } + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(mv), + EntityKind::Dimension(dm) => { + for p in [&mut dm.p13, &mut dm.p14, &mut dm.defpt, &mut dm.text_mid] { + mv(p); + } + dm.block = None; + } + k => { + let g = k.grips(); + if g.first().is_some_and(|p| bx.contains(*p)) { + k.transform(&Mat3::translate(d)); + } + } + } +} + +fn run_stretch(s: &mut Session, p: &Value) -> Result { + let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]] is required"))?; + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) }; + let bx = cadcraft_geom::Bounds2::new(*a, *b); + let d = delta_of("stretch", p)?; + let space = s.space(); + let hs = crate::select::select_window(s.doc()?, &space, bx, true); + let doc = s.doc_mut()?; + for h in &hs { + doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; + } + Ok(json!({ "stretched": hs.len() })) +} + +/// Offset an entity by `dist` towards `side`. Returns the new geometry. +pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option { + match k { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; + let o = ln.offset(dist * sgn); + Some(line(o.a, o.b)) + } + EntityKind::Circle(c) => { + let inside = side.dist(c.center.xy()) < c.radius; + let r = if inside { c.radius - dist } else { c.radius + dist }; + (r > 1e-12).then(|| circle(c.center.xy(), r)) + } + EntityKind::Arc(a) => { + let inside = side.dist(a.center.xy()) < a.radius; + let r = if inside { a.radius - dist } else { a.radius + dist }; + (r > 1e-12).then(|| arc(&Arc::new(a.center.xy(), r, a.start, a.end))) + } + EntityKind::Ellipse(e) => { + // Approximate with a polyline offset of the tessellation. + let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end }; + let inside = { + let mut pts = Vec::new(); + ge.tessellate(e.major.xy().len() * 1e-3, &mut pts); + cadcraft_geom::point_in_polygon(&pts, side) + }; + let mut pts = Vec::new(); + ge.tessellate(e.major.xy().len() * 1e-3, &mut pts); + let off = offset_points(&pts, if inside { -dist } else { dist }, ge.is_full()); + Some(lwpoly(off.into_iter().map(PolyVertex::new).collect(), ge.is_full())) + } + EntityKind::LwPolyline(p) => { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + // Side: compare with the nearest segment. + let segs = pl.segments(); + let nearest = segs.iter().min_by(|a, b| a.closest(side).dist(side).total_cmp(&b.closest(side).dist(side)))?; + let c = nearest.closest(side); + let t = nearest.tangent(0.5); + let left = t.cross(side - c) > 0.0; + let d = if left { dist } else { -dist }; + offset_polyline(&pl, d).map(|vs| { + let mut k = lwpoly(vs, p.closed); + if let EntityKind::LwPolyline(n) = &mut k { + n.const_width = p.const_width; + } + k + }) + } + EntityKind::Spline(sp) => { + let pts = sp.tessellate(1e-3); + let n = pts.len(); + let mid = pts.get(n / 2).copied()?; + let next = pts.get((n / 2 + 1).min(n.saturating_sub(1))).copied()?; + let left = (next - mid).cross(side - mid) > 0.0; + let off = offset_points(&pts, if left { dist } else { -dist }, sp.closed); + Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit_points(&decimate(&off, 40)))) + } + EntityKind::XLine(r) | EntityKind::Ray(r) => { + let ln = Line::new(r.base.xy(), r.base.xy() + r.dir.xy()); + let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; + let o = ln.offset(dist * sgn); + let rl = cadcraft_doc::RayLine { base: v3(o.a), dir: r.dir }; + Some(if matches!(k, EntityKind::XLine(_)) { EntityKind::XLine(rl) } else { EntityKind::Ray(rl) }) + } + _ => None, + } +} + +fn decimate(pts: &[Vec2], max: usize) -> Vec { + if pts.len() <= max { + return pts.to_vec(); + } + let step = pts.len() as f64 / max as f64; + let mut v: Vec = (0..max).filter_map(|i| pts.get((i as f64 * step) as usize).copied()).collect(); + if let Some(l) = pts.last() { + v.push(*l); + } + v +} + +fn offset_points(pts: &[Vec2], d: f64, closed: bool) -> Vec { + let n = pts.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let p = pts.get(i).copied().unwrap_or_default(); + let prev = if i > 0 { + pts.get(i - 1) + } else if closed { + pts.get(n.saturating_sub(2)) + } else { + None + }; + let next = if i + 1 < n { + pts.get(i + 1) + } else if closed { + pts.get(1) + } else { + None + }; + let t = match (prev, next) { + (Some(a), Some(b)) => (*b - *a).normalized(), + (None, Some(b)) => (*b - p).normalized(), + (Some(a), None) => (p - *a).normalized(), + _ => Vec2::X, + }; + out.push(p + t.perp() * d); + } + out +} + +/// Offset a polyline: offset each segment, then join neighbours at their intersections. +pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option> { + let segs: Vec = pl.segments().iter().filter_map(|s| s.offset(d)).collect(); + if segs.is_empty() { + return None; + } + let n = segs.len(); + let mut verts: Vec = Vec::new(); + for i in 0..n { + let s = segs.get(i)?; + let start = if i == 0 && !pl.closed { + s.start() + } else { + let prev = segs.get((i + n - 1) % n)?; + join_point(prev, s).unwrap_or(s.start()) + }; + let bulge = match s { + Segment::Line(_) => 0.0, + Segment::Arc { arc, ccw } => { + let sw = arc.sweep(); + cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw }) + } + }; + verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 }); + } + if !pl.closed { + verts.push(PolyVertex::new(segs.last()?.end())); + } + // Recompute arc bulges where endpoints moved (approximation: keep sweep sign). + Some(verts) +} + +fn join_point(a: &Segment, b: &Segment) -> Option { + if a.end().near(b.start(), 1e-9) { + return Some(b.start()); + } + let hits = intersect_ext(a, b, true); + hits.into_iter().min_by(|x, y| x.dist(a.end()).total_cmp(&y.dist(a.end()))) +} + +fn run_offset(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?; + let dist = f64_req("offset", p, "distance")?; + let side = point_req("offset", p, "side")?; + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; + let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; + let space = s.space(); + let d = s.doc_mut()?; + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common: e.common.clone(), kind: k }); + } + Ok(json!({ "handle": nh.hex() })) +} + +// ---------------- trim / extend ---------------- + +/// All primitives of the cutting edges (excluding `exclude`). +fn edge_segments(s: &Session, edges: Option<&[Handle]>, exclude: Handle) -> Result> { + let d = s.doc()?; + let space = s.space(); + let store = d.space(&space).ok_or(EngineError::NoDocument)?; + let mut out = Vec::new(); + for e in store.iter() { + if e.handle == exclude || !d.is_visible(e) { + continue; + } + if let Some(es) = edges + && !es.contains(&e.handle) + { + continue; + } + for p in e.kind.prims() { + out.extend(prim_to_segments(&p)); + } + } + Ok(out) +} + +fn prim_to_segments(p: &Prim) -> Vec { + match p { + Prim::Seg(s) => vec![*s], + Prim::Circle(c) => vec![Segment::Arc { arc: Arc { center: c.center, radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }], + Prim::Infinite { base, dir, .. } => vec![Segment::Line(Line::new(*base - *dir * 1e8, *base + *dir * 1e8))], + Prim::Ellipse(e) => { + let mut pts = Vec::new(); + e.tessellate(e.major.len() * 1e-4, &mut pts); + pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect() + } + Prim::Spline(sp) => sp.tessellate(1e-4).windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect(), + Prim::Fill(f) => { + let n = f.len(); + (0..n).filter_map(|i| Some(Segment::Line(Line::new(*f.get(i)?, *f.get((i + 1) % n)?)))).collect() + } + Prim::Point(_) => Vec::new(), + } +} + +/// Trim `h` at the piece containing `pick`. Returns the handles that replace it. +pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handle]>) -> Result> { + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let cut = edge_segments(s, edges, h)?; + let pieces: Vec = match &e.kind { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + let mut ts: Vec = cut + .iter() + .flat_map(|c| intersect_ext(&Segment::Line(ln), c, false)) + .map(|x| ln.param_of(x)) + .filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9) + .collect(); + ts.sort_by(f64::total_cmp); + let tp = ln.param_of(pick).clamp(0.0, 1.0); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(1.0, f64::min); + if lo == 0.0 && hi == 1.0 { + return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); + } + let mut v = Vec::new(); + if lo > 0.0 { + v.push(line(ln.a, ln.at(lo))); + } + if hi < 1.0 { + v.push(line(ln.at(hi), ln.b)); + } + v + } + EntityKind::Arc(a) => { + let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); + let seg = Segment::Arc { arc: ga, ccw: true }; + let sweep = ga.sweep(); + let param = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start); + let mut ts: Vec = + cut.iter().flat_map(|c| intersect_ext(&seg, c, false)).map(param).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); + ts.sort_by(f64::total_cmp); + let tp = param(pick).min(sweep); + let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); + if lo == 0.0 && hi == sweep { + return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); + } + let mut v = Vec::new(); + if lo > 0.0 { + v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + lo))); + } + if hi < sweep { + v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + hi, ga.end))); + } + v + } + EntityKind::Circle(c) => { + let full = Segment::Arc { arc: Arc { center: c.center.xy(), radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }; + let mut angs: Vec = cut.iter().flat_map(|x| intersect_ext(&full, x, false)).map(|p| c.center.xy().angle_to(p)).collect(); + angs.sort_by(f64::total_cmp); + angs.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + if angs.len() < 2 { + return Err(EngineError::Other("Circle must intersect cutting edges at two or more points.".into())); + } + let ap = c.center.xy().angle_to(pick); + // The removed arc runs between the two intersections around the pick. + let n = angs.len(); + let mut keep = None; + for i in 0..n { + let a0 = angs[i]; + let a1 = angs[(i + 1) % n]; + if angle_in_sweep(ap, a0, a1) { + keep = Some(arc(&Arc::new(c.center.xy(), c.radius, a1, a0))); + } + } + keep.into_iter().collect() + } + EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?, + _ => return Err(EngineError::Other("Cannot trim that object.".into())), + }; + let common = e.common.clone(); + let space = s.space(); + let d = s.doc_mut()?; + d.remove_entity(h); + let mut out = Vec::new(); + for k in pieces { + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common: common.clone(), kind: k }); + out.push(nh); + } + } + Ok(out) +} + +/// Sub-polyline between length parameters `a` and `b` (open polylines). +fn sub_polyline(segs: &[Segment], a: f64, b: f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut acc = 0.0; + for s in segs { + let len = s.len(); + let s0 = acc; + let s1 = acc + len; + acc = s1; + if s1 <= a + 1e-12 || s0 >= b - 1e-12 || len <= 1e-15 { + continue; + } + let t0 = ((a - s0) / len).clamp(0.0, 1.0); + let t1 = ((b - s0) / len).clamp(0.0, 1.0); + let p0 = s.at(t0); + let bulge = match s { + Segment::Line(_) => 0.0, + Segment::Arc { arc, ccw } => { + let sw = arc.sweep() * (t1 - t0); + cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw }) + } + }; + if out.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { + out.push(PolyVertex { p: p0, bulge, ..Default::default() }); + } else if let Some(l) = out.last_mut() { + l.bulge = bulge; + } + out.push(PolyVertex::new(s.at(t1))); + } + out +} + +fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> Result> { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + let segs = pl.segments(); + let total: f64 = segs.iter().map(Segment::len).sum(); + let mut ts: Vec = Vec::new(); + let mut acc = 0.0; + let mut tp = 0.0; + let mut best = f64::INFINITY; + for s in &segs { + let len = s.len(); + for x in cut.iter().flat_map(|c| intersect_ext(s, c, false)) { + ts.push(acc + param_on(s, x) * len); + } + let c = s.closest(pick); + if c.dist(pick) < best { + best = c.dist(pick); + tp = acc + param_on(s, c) * len; + } + acc += len; + } + ts.retain(|t| *t > 1e-9 && *t < total - 1e-9); + ts.sort_by(f64::total_cmp); + if ts.is_empty() { + return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); + } + let lo = ts.iter().copied().filter(|t| *t < tp).fold(f64::NEG_INFINITY, f64::max); + let hi = ts.iter().copied().filter(|t| *t > tp).fold(f64::INFINITY, f64::min); + let mk = |vs: Vec| lwpoly(vs, false); + let mut out = Vec::new(); + if p.closed { + // Keep the complementary run from hi around to lo. + let (lo, hi) = ( + if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total }, + if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total }, + ); + let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); + let (a, b) = if hi > total { (hi - total, lo + total) } else { (hi, lo + total) }; + out.push(mk(sub_polyline(&doubled, a, b))); + } else { + if lo.is_finite() { + out.push(mk(sub_polyline(&segs, 0.0, lo))); + } + if hi.is_finite() { + out.push(mk(sub_polyline(&segs, hi, total))); + } + } + Ok(out.into_iter().filter(|k| matches!(k, EntityKind::LwPolyline(l) if l.vertices.len() >= 2)).collect()) +} + +fn param_on(s: &Segment, p: Vec2) -> f64 { + match s { + Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0), + Segment::Arc { arc, ccw } => { + let t = norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15); + let t = t.clamp(0.0, 1.0); + if *ccw { t } else { 1.0 - t } + } + } +} + +/// Extend the end of `h` nearest `pick` to the nearest boundary edge. +pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handle]>) -> Result<()> { + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let cut = edge_segments(s, edges, h)?; + let new_kind = match &e.kind { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + let from_b = pick.dist(b) < pick.dist(a); + let (fixed, end) = if from_b { (a, b) } else { (b, a) }; + let dir = (end - fixed).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let best = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))); + let np = best.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; + if from_b { line(a, np) } else { line(np, b) } + } + EntityKind::Arc(a) => { + let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); + let from_end = pick.dist(ga.end_point()) < pick.dist(ga.start_point()); + let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..ga }, ccw: true }; + let hits: Vec = + cut.iter().flat_map(|c| intersect_ext(&full, c, false)).map(|x| ga.center.angle_to(x)).filter(|t| !ga.contains_angle(*t)).collect(); + let pick_ang = if from_end { + hits.iter().copied().min_by(|x, y| ccw_sweep(ga.end, *x).total_cmp(&ccw_sweep(ga.end, *y))) + } else { + hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ga.start).total_cmp(&ccw_sweep(*y, ga.start))) + }; + let t = pick_ang.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; + if from_end { arc(&Arc::new(ga.center, ga.radius, ga.start, t)) } else { arc(&Arc::new(ga.center, ga.radius, t, ga.end)) } + } + EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { + let mut vs = p.vertices.clone(); + let n = vs.len(); + let from_end = vs.last().is_some_and(|l| pick.dist(l.p) < vs.first().map(|f| pick.dist(f.p)).unwrap_or(f64::INFINITY)); + let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; + let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); + let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); + let dir = (end - prev).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let np = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) + .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } + lwpoly(vs, false) + } + _ => return Err(EngineError::Other("Cannot extend that object.".into())), + }; + s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?; + Ok(()) +} + +fn edges_param(p: &Value) -> Option> { + p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect()) +} + +fn run_trim(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("trim", "`handle` is required"))?; + let pick = point_req("trim", p, "pick")?; + let edges = edges_param(p); + let r = trim(s, h, pick, edges.as_deref())?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_extend(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("extend", "`handle` is required"))?; + let pick = point_req("extend", p, "pick")?; + let edges = edges_param(p); + extend(s, h, pick, edges.as_deref())?; + ok() +} + +// ---------------- fillet / chamfer ---------------- + +fn as_line(e: &Entity) -> Option { + match &e.kind { + EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())), + _ => None, + } +} + +/// Fillet (or chamfer) two lines. `p1`/`p2` mark the kept side of each line. +pub(crate) fn fillet_lines( + s: &mut Session, + h1: Handle, + p1: Vec2, + h2: Handle, + p2: Vec2, + radius: f64, + chamfer: Option<(f64, f64)>, +) -> Result> { + let d = s.doc()?; + let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else { + return Err(EngineError::Other("Fillet/chamfer currently works on lines.".into())); + }; + let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; + // Keep the far end on the picked side of the corner. + let keep = |l: &Line, p: Vec2| -> Vec2 { + let ta = (l.a - x).dot(p - x); + let tb = (l.b - x).dot(p - x); + if ta >= tb { l.a } else { l.b } + }; + let k1 = keep(&l1, p1); + let k2 = keep(&l2, p2); + let u1 = (k1 - x).normalized(); + let u2 = (k2 - x).normalized(); + let mut new_arc = None; + let (t1, t2) = if let Some((d1, d2)) = chamfer { + (x + u1 * d1, x + u2 * d2) + } else if radius <= 1e-12 { + (x, x) + } else { + let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; + if half.sin().abs() < 1e-12 { + return Err(EngineError::Other("Lines are parallel.".into())); + } + let dist = radius / half.tan(); + let t1 = x + u1 * dist; + let t2 = x + u2 * dist; + let bis = (u1 + u2).normalized(); + let c = x + bis * (radius / half.sin()); + let a1 = c.angle_to(t1); + let a2 = c.angle_to(t2); + let ar = if ccw_sweep(a1, a2) <= std::f64::consts::PI { Arc::new(c, radius, a1, a2) } else { Arc::new(c, radius, a2, a1) }; + new_arc = Some(ar); + (t1, t2) + }; + let common = e1.common.clone(); + let doc = s.doc_mut()?; + doc.modify_entity(h1, |e| e.kind = line(k1, t1))?; + doc.modify_entity(h2, |e| e.kind = line(k2, t2))?; + let space = s.space(); + let doc = s.doc_mut()?; + let extra = match (chamfer, new_arc) { + (Some(_), _) if !t1.near(t2, 1e-12) => Some(line(t1, t2)), + (None, Some(a)) => Some(arc(&a)), + _ => None, + }; + if let Some(k) = extra { + let nh = doc.new_handle(); + if let Some(st) = doc.space_mut(&space) { + st.push(Entity { handle: nh, common, kind: k }); + } + return Ok(Some(nh)); + } + Ok(None) +} + +fn h_param(p: &Value, k: &str) -> Option { + p.get(k).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) +} + +fn run_fillet(s: &mut Session, p: &Value) -> Result { + let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?; + let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?; + let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); + let p1 = point_param(p, "p1").unwrap_or_default(); + let p2 = point_param(p, "p2").unwrap_or_default(); + let a = fillet_lines(s, h1, p1, h2, p2, r, None)?; + Ok(json!({ "arc": a.map(|h| h.hex()) })) +} + +fn run_chamfer(s: &mut Session, p: &Value) -> Result { + let h1 = h_param(p, "h1").ok_or_else(|| bad("chamfer", "`h1` is required"))?; + let h2 = h_param(p, "h2").ok_or_else(|| bad("chamfer", "`h2` is required"))?; + let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); + let d2 = f64_or(p, "d2", d1); + let p1 = point_param(p, "p1").unwrap_or_default(); + let p2 = point_param(p, "p2").unwrap_or_default(); + let a = fillet_lines(s, h1, p1, h2, p2, 0.0, Some((d1, d2)))?; + Ok(json!({ "line": a.map(|h| h.hex()) })) +} + +// ---------------- explode / array / order / break / join ---------------- + +pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option> { + match &e.kind { + EntityKind::LwPolyline(p) => { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + Some( + pl.segments() + .into_iter() + .map(|s| { + let k = match s { + Segment::Line(l) => line(l.a, l.b), + Segment::Arc { arc: a, .. } => arc(&a), + }; + Entity { handle: Handle(0), common: e.common.clone(), kind: k } + }) + .collect(), + ) + } + EntityKind::Insert(ins) => { + let blk = d.block(&ins.block)?; + let m = ins.transform(blk.base.xy()); + Some( + blk.entities + .iter() + .map(|be| { + let mut ne = (**be).clone(); + ne.kind.transform(&m); + if ne.common.layer == "0" { + ne.common.layer = e.common.layer.clone(); + } + if ne.common.color == cadcraft_color::Color::ByBlock { + ne.common.color = e.common.color; + } + ne + }) + .collect(), + ) + } + EntityKind::Polyline3d(p) => Some( + p.points + .windows(2) + .filter_map(|w| Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(w.first()?.xy(), w.get(1)?.xy()) })) + .collect(), + ), + EntityKind::MText(m) => Some( + cadcraft_fonts::plain_mtext(&m.contents) + .split('\n') + .enumerate() + .map(|(i, l)| { + let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); + Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Text(cadcraft_doc::Text { + insert: v3(at), + align_pt: None, + height: m.height, + value: l.into(), + rotation: m.rotation, + width_factor: 1.0, + oblique: 0.0, + style: m.style.clone(), + halign: Default::default(), + valign: Default::default(), + }), + } + }) + .collect(), + ), + EntityKind::Dimension(dm) => { + let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); + let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0)); + let mut v: Vec = g + .lines + .iter() + .filter_map(|l| { + if l.len() == 2 { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) + } else { + Some(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false), + }) + } + }) + .collect(); + for t in &g.fills { + if let [a, b, c] = t.as_slice() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), + }); + } + } + let th = style.text_height * style.scale.max(1e-9); + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::MText(cadcraft_doc::MText { + insert: v3(g.text_pos), + height: th, + width: 0.0, + attach: 5, + rotation: 0.0, + style: style.text_style.clone(), + contents: g.value.clone(), + line_spacing: 1.0, + }), + }); + Some(v) + } + _ => None, + } +} + +fn explode(s: &mut Session, hs: &[Handle]) -> Result> { + let space = s.space(); + let mut out = Vec::new(); + for h in hs { + let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue }; + let Some(parts) = explode_kind(s.doc()?, &e) else { continue }; + let d = s.doc_mut()?; + d.remove_entity(*h); + for mut p in parts { + p.handle = d.new_handle(); + out.push(p.handle); + if let Some(st) = d.space_mut(&space) { + st.push(p); + } + } + } + s.set_selection(Vec::new()); + Ok(out) +} + +fn run_explode(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let r = explode(s, &hs)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_arrayrect(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(3).clamp(1, 1000); + let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(4).clamp(1, 1000); + let rs = f64_or(p, "rowSpacing", 1.0); + let cs = f64_or(p, "colSpacing", 1.0); + let mut out = Vec::new(); + for r in 0..rows { + for c in 0..cols { + if r == 0 && c == 0 { + continue; + } + out.extend(transform_entities(s, &hs, &Mat3::translate(Vec2::new(cs * c as f64, rs * r as f64)), true)?); + } + } + Ok(json!({ "created": out.len() })) +} + +fn run_arraypolar(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let c = point_req("arraypolar", p, "center")?; + let n = p.get("count").and_then(Value::as_u64).unwrap_or(6).clamp(1, 10_000); + let total = f64_or(p, "angle", 360.0).to_radians(); + let rotate = bool_or(p, "rotate", true); + let step = if (total - TAU).abs() < 1e-9 { total / n as f64 } else { total / (n.saturating_sub(1).max(1)) as f64 }; + let mut out = Vec::new(); + for k in 1..n { + let a = step * k as f64; + let m = if rotate { + Mat3::rotate_about(c, a) + } else { + // Translate only: move the reference point around the circle. + let d = s.doc()?; + let r = hs.first().and_then(|h| d.entity(*h)).map(|e| cadcraft_doc::entity_bounds(d, e, 0).center()).unwrap_or(c); + Mat3::translate(r.rotate_about(c, a) - r) + }; + out.extend(transform_entities(s, &hs, &m, true)?); + } + Ok(json!({ "created": out.len() })) +} + +fn run_front(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let space = s.space(); + let d = s.doc_mut()?; + if let Some(st) = d.space_mut(&space) { + for h in hs { + st.bring_to_front(h); + } + } + ok() +} + +fn run_back(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let space = s.space(); + let d = s.doc_mut()?; + if let Some(st) = d.space_mut(&space) { + for h in hs.into_iter().rev() { + st.send_to_back(h); + } + } + ok() +} + +/// Break `h` between two points (or at one point when p1 == p2). +pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result> { + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let pieces: Vec = match &e.kind { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + let (mut t1, mut t2) = (ln.param_of(p1).clamp(0.0, 1.0), ln.param_of(p2).clamp(0.0, 1.0)); + if t1 > t2 { + std::mem::swap(&mut t1, &mut t2); + } + let mut v = Vec::new(); + if t1 > 1e-12 { + v.push(line(ln.a, ln.at(t1))); + } + if t2 < 1.0 - 1e-12 { + v.push(line(ln.at(t2), ln.b)); + } + v + } + EntityKind::Circle(c) => { + if p1.near(p2, 1e-12) { + return Err(EngineError::Other("Arc cannot be full 360 degrees.".into())); + } + let a1 = c.center.xy().angle_to(p1); + let a2 = c.center.xy().angle_to(p2); + // AutoCAD removes the CCW portion from the first to the second point. + vec![arc(&Arc::new(c.center.xy(), c.radius, a2, a1))] + } + EntityKind::Arc(a) => { + let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); + let par = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start).min(ga.sweep()); + let (mut t1, mut t2) = (par(p1), par(p2)); + if t1 > t2 { + std::mem::swap(&mut t1, &mut t2); + } + let mut v = Vec::new(); + if t1 > 1e-12 { + v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1))); + } + if t2 < ga.sweep() - 1e-12 { + v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end))); + } + v + } + EntityKind::LwPolyline(p) if !p.closed => { + let pl = Polyline { vertices: p.vertices.clone(), closed: false }; + let segs = pl.segments(); + let total: f64 = segs.iter().map(Segment::len).sum(); + let lp = |q: Vec2| { + let mut acc = 0.0; + let mut best = (f64::INFINITY, 0.0); + for s in &segs { + let c = s.closest(q); + let dd = c.dist(q); + if dd < best.0 { + best = (dd, acc + param_on(s, c) * s.len()); + } + acc += s.len(); + } + best.1 + }; + let (mut t1, mut t2) = (lp(p1), lp(p2)); + if t1 > t2 { + std::mem::swap(&mut t1, &mut t2); + } + let mut v = Vec::new(); + if t1 > 1e-12 { + v.push(lwpoly(sub_polyline(&segs, 0.0, t1), false)); + } + if t2 < total - 1e-12 { + v.push(lwpoly(sub_polyline(&segs, t2, total), false)); + } + v + } + _ => return Err(EngineError::Other("Cannot break that object.".into())), + }; + let common = e.common.clone(); + let space = s.space(); + let d = s.doc_mut()?; + d.remove_entity(h); + let mut out = Vec::new(); + for k in pieces { + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common: common.clone(), kind: k }); + out.push(nh); + } + } + Ok(out) +} + +fn run_break(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("break", "`handle` is required"))?; + let p1 = point_req("break", p, "p1")?; + let p2 = point_param(p, "p2").unwrap_or(p1); + let r = break_entity(s, h, p1, p2)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +fn run_breakat(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("breakatpoint", "`handle` is required"))?; + let at = point_req("breakatpoint", p, "at")?; + let r = break_entity(s, h, at, at)?; + Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) +} + +/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines). +pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result> { + let d = s.doc()?; + let mut segs: Vec = Vec::new(); + let mut common = None; + for h in hs { + let Some(e) = d.entity(*h) else { continue }; + common.get_or_insert_with(|| e.common.clone()); + match &e.kind { + EntityKind::Line(l) => segs.push(Segment::Line(Line::new(l.a.xy(), l.b.xy()))), + EntityKind::Arc(a) => segs.push(Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), + EntityKind::LwPolyline(p) if !p.closed => segs.extend(Polyline { vertices: p.vertices.clone(), closed: false }.segments()), + _ => {} + } + } + if segs.len() < 2 { + return Err(EngineError::Other("Select at least two objects to join.".into())); + } + // Chain greedily from the first segment. + let mut chain = vec![segs.remove(0)]; + let tol = 1e-6; + loop { + let start = chain.first().map(Segment::start).unwrap_or_default(); + let end = chain.last().map(Segment::end).unwrap_or_default(); + let Some(i) = + segs.iter().position(|s| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol)) + else { + break; + }; + let s0 = segs.remove(i); + if s0.start().near(end, tol) { + chain.push(s0); + } else if s0.end().near(end, tol) { + chain.push(s0.reversed()); + } else if s0.end().near(start, tol) { + chain.insert(0, s0); + } else { + chain.insert(0, s0.reversed()); + } + } + if chain.len() < 2 { + return Err(EngineError::Other("Objects are not connected.".into())); + } + let closed = chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol)); + let mut vs: Vec = chain + .iter() + .map(|s| PolyVertex { + p: s.start(), + bulge: match s { + Segment::Line(_) => 0.0, + Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }), + }, + ..Default::default() + }) + .collect(); + if !closed && let Some(l) = chain.last() { + vs.push(PolyVertex::new(l.end())); + } + let space = s.space(); + let doc = s.doc_mut()?; + for h in hs { + doc.remove_entity(*h); + } + let nh = doc.new_handle(); + if let Some(st) = doc.space_mut(&space) { + st.push(Entity { handle: nh, common: common.unwrap_or_default(), kind: lwpoly(vs, closed) }); + } + s.set_selection(Vec::new()); + Ok(Some(nh)) +} + +fn run_join(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let r = join(s, &hs)?; + Ok(json!({ "handle": r.map(|h| h.hex()) })) +} + +fn run_overkill(s: &mut Session, p: &Value) -> Result { + let hs = if p.get("handles").is_some() { targets(s, p)? } else { s.doc()?.space(&s.space()).map(|st| st.handles()).unwrap_or_default() }; + let d = s.doc()?; + let mut seen: Vec<(String, String)> = Vec::new(); + let mut dup = Vec::new(); + for h in hs { + let Some(e) = d.entity(h) else { continue }; + let key = (serde_json::to_string(&e.kind).unwrap_or_default(), serde_json::to_string(&e.common).unwrap_or_default()); + if seen.contains(&key) { + dup.push(h); + } else { + seen.push(key); + } + } + let n = erase(s, &dup)?; + Ok(json!({ "deleted": n, "message": format!("{n} duplicate(s) deleted") })) +} + +// ---------------- interactive ---------------- + +#[derive(Clone, Copy, PartialEq)] +enum Op { + Erase, + Move, + Copy, + Rotate, + Scale, + Mirror, + Stretch, + Explode, + ArrayRect, + ArrayPolar, + Join, +} + +/// Select objects, then collect the points/values the operation needs. +struct SelectThen { + op: Op, + sel: SelectPhase, + objs: Vec, + pts: Vec, + reference: bool, + copy_mode: bool, + window: Option, + ask_erase: bool, +} + +impl SelectThen { + fn new(op: Op) -> Self { + SelectThen { + op, + sel: SelectPhase::default(), + objs: Vec::new(), + pts: Vec::new(), + reference: false, + copy_mode: false, + window: None, + ask_erase: false, + } + } + fn apply(&mut self, s: &mut Session) -> Result { + // Immediate operations once selection is done. + match self.op { + Op::Erase => { + let n = erase(s, &self.objs)?; + let _ = n; + Ok(Step::Done) + } + Op::Explode => { + explode(s, &self.objs)?; + Ok(Step::Done) + } + Op::Join => { + match join(s, &self.objs) { + Ok(_) => s.echo(format!("{} objects joined into 1 polyline", self.objs.len())), + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } + Op::ArrayRect => { + let objs = self.objs.clone(); + let d = s.doc()?; + let b = objs + .iter() + .filter_map(|h| d.entity(*h).map(|e| cadcraft_doc::entity_bounds(d, e, 0))) + .fold(cadcraft_geom::Bounds2::EMPTY, |a, b| a.union(&b)); + let cs = b.width() * 1.5 + 1e-9; + let rs = b.height() * 1.5 + 1e-9; + run_arrayrect( + s, + &json!({ "handles": objs.iter().map(|h| h.hex()).collect::>(), "rows": 3, "cols": 4, "rowSpacing": rs, "colSpacing": cs }), + )?; + s.echo("Type = Rectangular Associative = No (3 rows × 4 columns)"); + Ok(Step::Done) + } + _ => Ok(Step::Continue), + } + } +} + +impl Interactive for SelectThen { + fn name(&self) -> &'static str { + match self.op { + Op::Erase => "ERASE", + Op::Move => "MOVE", + Op::Copy => "COPY", + Op::Rotate => "ROTATE", + Op::Scale => "SCALE", + Op::Mirror => "MIRROR", + Op::Stretch => "STRETCH", + Op::Explode => "EXPLODE", + Op::ArrayRect => "ARRAYRECT", + Op::ArrayPolar => "ARRAYPOLAR", + Op::Join => "JOIN", + } + } + fn begin(&mut self, s: &mut Session) -> Result { + if self.op == Op::Stretch { + s.set_selection(Vec::new()); + s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); + return Ok(Step::Continue); + } + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + return self.apply(s); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.op == Op::Stretch && self.window.is_none() { + return match self.pts.first() { + None => Prompt::new("Select objects (crossing window first corner)", Accept::POINT), + Some(p) => Prompt::new("Specify opposite corner", Accept::POINT).base(*p), + }; + } + if !self.sel.done && self.op != Op::Stretch { + return self.sel.prompt(); + } + if self.ask_erase { + return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); + } + let n = self.pts.len(); + let base = self.pts.first().copied(); + let pts_from = if self.op == Op::Stretch { 2 } else { 0 }; + let k = n.saturating_sub(pts_from); + let bp = if self.op == Op::Stretch { self.pts.get(2).copied() } else { base }; + match (self.op, k) { + (Op::Move | Op::Copy | Op::Stretch, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), + (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), + (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), + (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), + (Op::Rotate, _) => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).kw(&["Copy", "Reference"]).default("0").base_opt(base), + (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).kw(&["Copy", "Reference"]).base_opt(base), + (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), + (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), + (Op::ArrayPolar, 0) => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base point", "Axis of rotation"]), + (Op::ArrayPolar, _) => Prompt::new("Enter number of items", Accept::NUMBER).default("6"), + _ => Prompt::new("", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.op == Op::Stretch && self.window.is_none() { + if let Input::Point(p) = i { + self.pts.push(p); + if self.pts.len() == 2 { + let bx = cadcraft_geom::Bounds2::new(self.pts[0], self.pts[1]); + self.window = Some(bx); + let space = s.space(); + self.objs = crate::select::select_window(s.doc()?, &space, bx, true); + s.echo(format!("{} found", self.objs.len())); + s.set_selection(self.objs.clone()); + self.sel.done = true; + if self.objs.is_empty() { + return Ok(Step::Done); + } + } + } + return Ok(Step::Continue); + } + if !self.sel.done { + match self.sel.feed(s, &i)? { + SelOutcome::More => return Ok(Step::Continue), + SelOutcome::Empty => return Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + return self.apply(s); + } + } + } + if self.ask_erase { + let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y')); + let (a, b) = (self.pts[0], self.pts[1]); + transform_entities(s, &self.objs, &Mat3::mirror(a, b), !yes)?; + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + let off = if self.op == Op::Stretch { 2 } else { 0 }; + let k = self.pts.len() - off; + match (self.op, k, i) { + (_, _, Input::Keyword(kw)) if kw == "Copy" => { + self.copy_mode = true; + s.echo("Rotating/scaling a copy of the selected objects."); + Ok(Step::Continue) + } + (_, _, Input::Keyword(kw)) if kw == "Reference" => { + self.reference = true; + s.echo("Reference: not available yet; enter a value."); + Ok(Step::Continue) + } + (Op::Copy, _, Input::Keyword(kw)) if kw == "Exit" => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, 0, Input::Point(p)) => { + self.pts.push(p); + Ok(Step::Continue) + } + (Op::Move, 1, Input::Point(p)) => { + let d = p - self.pts[0]; + transform_entities(s, &self.objs, &Mat3::translate(d), false)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Move, 1, Input::Enter) => { + let d = self.pts[0]; + transform_entities(s, &self.objs, &Mat3::translate(d), false)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Stretch, 1, Input::Point(p)) => { + let d = p - self.pts[2]; + if let Some(bx) = self.window { + let doc = s.doc_mut()?; + for h in &self.objs { + doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; + } + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Copy, _, Input::Point(p)) => { + let d = p - self.pts[0]; + transform_entities(s, &self.objs, &Mat3::translate(d), true)?; + Ok(Step::Continue) + } + (Op::Copy, _, Input::Enter) => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Rotate, 1, Input::Point(p)) => { + let a = self.pts[0].angle_to(p); + transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Rotate, 1, Input::Text(t)) => { + let a = crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?; + transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Scale, 1, Input::Point(p)) => { + let f = self.pts[0].dist(p); + if f > 1e-12 { + transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Scale, 1, Input::Text(t)) => { + let f = number(&t).filter(|f| *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))?; + transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Op::Mirror, 1, Input::Point(p)) => { + if p.near(self.pts[0], 1e-12) { + return Err(EngineError::Other("Mirror line points must differ.".into())); + } + self.pts.push(p); + self.ask_erase = true; + Ok(Step::Continue) + } + (Op::ArrayPolar, 1, inp) => { + let n = match inp { + Input::Text(t) => { + t.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))? + } + Input::Enter => 6, + _ => return Ok(Step::Continue), + }; + let objs: Vec = self.objs.iter().map(|h| h.hex()).collect(); + run_arraypolar(s, &json!({ "handles": objs, "center": [self.pts[0].x, self.pts[0].y], "count": n }))?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, _, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if !self.sel.done { + return Vec::new(); + } + let Some(base) = (if self.op == Op::Stretch { self.pts.get(2) } else { self.pts.first() }).copied() else { return Vec::new() }; + let m = match self.op { + Op::Move | Op::Copy | Op::Stretch => Mat3::translate(c - base), + Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), + Op::Scale => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), + _ => return Vec::new(), + }; + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| d.entity(*h)) + .map(|e| { + let mut k = e.kind.clone(); + if self.op == Op::Stretch { + let mut ee = (**e).clone(); + if let Some(bx) = self.window { + stretch_entity(&mut ee, &bx, c - base); + } + return ee.kind; + } + k.transform(&m); + k + }) + .collect(); + out.push(line(base, c)); + out + } +} + +struct OffsetM { + dist: Option, + through: bool, + picked: Option, +} + +impl OffsetM { + fn new(s: &Session) -> Self { + let _ = s; + OffsetM { dist: None, through: false, picked: None } + } +} + +impl Interactive for OffsetM { + fn name(&self) -> &'static str { + "OFFSET" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo("Current settings: Erase source=No Layer=Source OFFSETGAPTYPE=0"); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0); + match (self.dist.is_some() || self.through, self.picked) { + (false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 { + "Through".to_string() + } else { + format!("{last:.4}") + }), + (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]), + (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]), + (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.dist.is_none() && !self.through { + match i { + Input::Text(t) => { + let d = number(&t).filter(|d| *d > 0.0).ok_or_else(|| EngineError::Other("Requires a positive distance.".into()))?; + self.dist = Some(d); + s.doc_mut()?.header.set_f64("OFFSETDIST", d); + } + Input::Keyword(k) if k == "Through" => self.through = true, + Input::Enter => { + let last = s.doc()?.header.f64("OFFSETDIST", -1.0); + if last > 0.0 { + self.dist = Some(last); + } else { + self.through = true; + } + } + _ => {} + } + return Ok(Step::Continue); + } + match (self.picked, i) { + (_, Input::Keyword(k)) if k == "Exit" => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, Input::Enter) => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (None, Input::Point(p)) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + match crate::select::pick(s.doc()?, &space, p, ap) { + Some(h) => { + self.picked = Some(h); + s.set_selection(vec![h]); + } + None => s.echo("*Invalid selection*"), + } + Ok(Step::Continue) + } + (Some(h), Input::Point(p)) => { + let e = s.doc()?.entity(h).map(|e| (**e).clone()); + if let Some(e) = e { + let dist = match self.dist { + Some(d) => d, + None => { + let polys = crate::select::hit_polylines(s.doc()?, &e, 1e-4); + polys + .iter() + .flat_map(|pl| pl.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).dist(p)))) + .fold(f64::INFINITY, f64::min) + } + }; + match offset_kind(&e.kind, dist, p) { + Some(k) => { + let space = s.space(); + let d = s.doc_mut()?; + let nh = d.new_handle(); + if let Some(st) = d.space_mut(&space) { + st.push(Entity { handle: nh, common: e.common.clone(), kind: k }); + } + } + None => s.echo("Cannot offset that object."), + } + } + self.picked = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + let (Some(h), Ok(d)) = (self.picked, s.doc()) else { return Vec::new() }; + let Some(e) = d.entity(h) else { return Vec::new() }; + let dist = match self.dist { + Some(x) => x, + None => crate::select::entity_distance(d, e, c, 1e-3), + }; + offset_kind(&e.kind, dist, c).into_iter().collect() + } +} + +struct TrimM { + extend: bool, +} + +impl Interactive for TrimM { + fn name(&self) -> &'static str { + if self.extend { "EXTEND" } else { "TRIM" } + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo("Current settings: Projection=UCS, Edge=None, Mode=Quick"); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.extend { + Prompt::new("Select object to extend or shift-select to trim", Accept::POINT).kw(&[ + "Boundary edges", + "Fence", + "Crossing", + "mOde", + "Project", + "Undo", + ]) + } else { + Prompt::new("Select object to trim or shift-select to extend", Accept::POINT).kw(&[ + "cuTting edges", + "Fence", + "Crossing", + "mOde", + "Project", + "eRase", + ]) + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { + s.echo("*Invalid selection*"); + return Ok(Step::Continue); + }; + let r = if self.extend { extend(s, h, p, None).map(|_| ()) } else { trim(s, h, p, None).map(|_| ()) }; + if let Err(e) = r { + s.echo(e.to_string()); + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Done), + Input::Keyword(k) => { + s.echo(format!("{k}: not available yet")); + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + } + } +} + +struct FilletM { + chamfer: bool, + first: Option<(Handle, Vec2)>, + asking: bool, +} + +impl FilletM { + fn new(_s: &Session, chamfer: bool) -> Self { + FilletM { chamfer, first: None, asking: false } + } +} + +impl Interactive for FilletM { + fn name(&self) -> &'static str { + if self.chamfer { "CHAMFER" } else { "FILLET" } + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + let d = s.doc()?; + let msg = if self.chamfer { + format!("(TRIM mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)) + } else { + format!("Current settings: Mode = TRIM, Radius = {:.4}", d.header.f64("FILLETRAD", 0.0)) + }; + s.echo(msg); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.asking { + return Prompt::new(if self.chamfer { "Specify first chamfer distance" } else { "Specify fillet radius" }, Accept::NUMBER); + } + match (self.first, self.chamfer) { + (None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]), + (None, true) => { + Prompt::new("Select first line", Accept::POINT).kw(&["Undo", "Polyline", "Distance", "Angle", "Trim", "mEthod", "Multiple"]) + } + (Some(_), false) => Prompt::new("Select second object or shift-select to apply corner", Accept::POINT).kw(&["Radius"]), + (Some(_), true) => Prompt::new("Select second line or shift-select to apply corner", Accept::POINT).kw(&["Distance", "Angle", "Method"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.asking { + if let Input::Text(t) = &i { + let v = number(t).filter(|v| *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?; + let d = s.doc_mut()?; + if self.chamfer { + d.header.set_f64("CHAMFERA", v); + d.header.set_f64("CHAMFERB", v); + } else { + d.header.set_f64("FILLETRAD", v); + } + } + self.asking = false; + return Ok(Step::Continue); + } + match i { + Input::Keyword(k) if k == "Radius" || k == "Distance" => { + self.asking = true; + Ok(Step::Continue) + } + Input::Point(p) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { + s.echo("*Invalid selection*"); + return Ok(Step::Continue); + }; + match self.first { + None => { + self.first = Some((h, p)); + s.set_selection(vec![h]); + Ok(Step::Continue) + } + Some((h1, p1)) => { + let d = s.doc()?; + let r = d.header.f64("FILLETRAD", 0.0); + let ch = (d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)); + let res = fillet_lines(s, h1, p1, h, p, r, if self.chamfer { Some(ch) } else { None }); + s.set_selection(Vec::new()); + match res { + Ok(_) => Ok(Step::Done), + Err(e) => { + s.echo(e.to_string()); + self.first = None; + Ok(Step::Continue) + } + } + } + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} + +#[derive(Default)] +struct BreakM { + obj: Option<(Handle, Vec2)>, +} + +impl Interactive for BreakM { + fn name(&self) -> &'static str { + "BREAK" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.obj { + None => Prompt::new("Select object", Accept::POINT), + Some(_) => Prompt::new("Specify second break point", Accept::POINT).kw(&["First point"]), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.obj, i) { + (None, Input::Point(p)) => { + let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + match crate::select::pick(s.doc()?, &space, p, ap) { + Some(h) => { + self.obj = Some((h, p)); + s.set_selection(vec![h]); + } + None => s.echo("*Invalid selection*"), + } + Ok(Step::Continue) + } + (Some((h, p1)), Input::Point(p2)) => { + break_entity(s, h, p1, p2)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} + +pub(crate) fn _unused(_: Circle) {} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs new file mode 100644 index 00000000..d9c5515f --- /dev/null +++ b/crates/engine/src/cmd/props.rs @@ -0,0 +1,459 @@ +//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units. + +use cadcraft_color::Color; +use cadcraft_doc::{EntityKind, Lineweight}; +use cadcraft_geom::Vec3; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(), + CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?, visible?, }"), + CommandSpec::new("matchprop", "Match Properties", run_matchprop).menu(&["Modify", "Match Properties"]).alias(&["ma", "painter"]).params("{source, targets: [hex]}"), + CommandSpec::new("color", "Color...", run_color).menu(&["Format", "Color..."]).alias(&["col", "colour"]).params("{color: \"ByLayer\" | \"red\" | 1..255 | \"r,g,b\"}"), + CommandSpec::new("linetype", "Linetype...", run_linetype).menu(&["Format", "Linetype..."]).alias(&["lt", "ltype"]).params("{current?: name, load?: name | \"*\"}"), + CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), + CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}"), + CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"), + CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"), + CommandSpec::new("style", "Text Style...", run_style).menu(&["Format", "Text Style..."]).alias(&["st"]).params("{name, font?, height?, widthFactor?, oblique?, current?}"), + CommandSpec::new("dimstyle", "Dimension Style...", run_dimstyle).menu(&["Format", "Dimension Style..."]).alias(&["d", "dst", "ddim"]).params("{name, current?,