mirror of
https://github.com/aphrody-labs/cadcraft.git
synced 2026-10-13 19:26:54 +00:00
UI: retain shared workspace while merging current main
This commit is contained in:
commit
58a8494f98
272 files changed
+37546
-2614
No files matched your search
@@ -213,6 +213,67 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$CADCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
@@ -330,7 +391,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, web, freebsd]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd]
|
||||
# Only the `release` branch publishes; a dry run on another branch stops before this job.
|
||||
if: github.ref == 'refs/heads/release'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -17,7 +17,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** 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.
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) 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)]`.
|
||||
|
||||
@@ -46,7 +46,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` recomputes the command-catalog parity in `docs/parity.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
||||
@@ -51,3 +51,9 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
|
||||
## Interface translations
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
@@ -17,7 +17,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** 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.
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) 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)]`.
|
||||
|
||||
@@ -46,4 +46,4 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` recomputes the command-catalog parity in `docs/parity.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
Generated
+29
-18
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1db2bd3da02ed787f3f5c8afba8779dd104b4459b78bb1431b1a0211cc3719f0"
|
||||
checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -540,7 +540,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -551,12 +551,14 @@ dependencies = [
|
||||
"muda",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -567,14 +569,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -584,7 +586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -595,21 +597,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -626,7 +628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"skrifa 0.47.0",
|
||||
@@ -634,14 +636,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -657,7 +659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -668,7 +670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -680,7 +682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -701,7 +703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -3490,6 +3492,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -4672,7 +4683,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -105,12 +105,13 @@ numbers):
|
||||
| Modify | ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, STRETCH, OFFSET, TRIM, EXTEND, FILLET, CHAMFER, BREAK, JOIN, EXPLODE, rectangular/polar ARRAY, draw order, OVERKILL |
|
||||
| Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest; deferred tangent/perpendicular for the first point of a line, e.g. belt lines tangent to two circles), polar tracking, ortho, grid snap |
|
||||
| Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour |
|
||||
| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts, MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font |
|
||||
| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts (`.ttf`, `.otf`, `.ttc` collections), MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font |
|
||||
| Hatch & blocks | Pick-point hatch boundaries with islands, pattern, solid and gradient fills from our own pattern library, BLOCK/INSERT, attributes and nested blocks |
|
||||
| Files | DXF read/write (R12–2018, including dimension styles, associativity, tables and constraints), DWG read/write (R13–2018, via the acadrust library), PDF plotting, SVG and PNG export |
|
||||
| Files | DXF read (R12–2018, ASCII and binary) and write (R2000), including dimension styles, associativity, tables and constraints; DWG read (via the acadrust library) and write (R2000); PDF plotting, SVG and PNG export. Entities CADCraft doesn't model yet are not kept on save: see [file-format parity](docs/file-format-parity.md) |
|
||||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English and Ukrainian (Українська), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
@@ -135,6 +136,39 @@ zoom e zoom to extents
|
||||
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)).
|
||||
|
||||
### Fonts
|
||||
|
||||
CADCraft never ships fonts; it uses the TrueType/OpenType fonts installed on your system. SHX
|
||||
fonts (including big fonts such as `chineset.shx`) aren't read yet: text in an SHX style is drawn
|
||||
with our own stroke font, and any character a text's font lacks (CJK, for example) is drawn from
|
||||
an installed font that has it, preferring wide-coverage fonts such as Noto Sans CJK, Source Han
|
||||
Sans, WenQuanYi, PingFang or Microsoft JhengHei. Two settings, set with `SETVAR` or (for every
|
||||
run) an environment variable, steer this:
|
||||
|
||||
| Variable | Environment | Meaning |
|
||||
|---|---|---|
|
||||
| `FONTALT` | `CADCRAFT_FONTALT` | Font used instead of a text style font that can't be found (an SHX file, a missing TTF), by file or family name, e.g. `Noto Sans CJK TC`. Empty (default) = our stroke font. |
|
||||
| `FONTFALLBACK` | `CADCRAFT_FONTFALLBACK` | Fonts tried first for characters a text's font lacks, comma-separated, e.g. `Noto Sans CJK TC, msjh.ttc`. |
|
||||
|
||||
`.` clears either one.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the
|
||||
settings directory (Linux and the BSDs `~/.config/cadcraft/logs/`, or
|
||||
`$XDG_CONFIG_HOME/cadcraft/logs/`; macOS `~/Library/Application Support/CADCraft/logs/`; Windows
|
||||
`%APPDATA%\CADCraft\logs\`; or under `CADCRAFT_CONFIG_DIR`). A start from a desktop menu or the
|
||||
Dock has no terminal, so this file is what to attach to a bug report. Each launch moves the
|
||||
previous log to `cadcraft.1.log` (and that one to `cadcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) |
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. |
|
||||
|
||||
The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead.
|
||||
|
||||
## Drive it from agents, MCP and the CLI
|
||||
|
||||
```sh
|
||||
@@ -190,9 +224,13 @@ More menu, and status readouts truncate with a full-value tooltip before they re
|
||||
Painted toolbar and tab controls
|
||||
have accessible labels and visible keyboard focus. Property controls remain application-specific.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for milestones, measured parity and the estimate of remaining work.
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
|
||||
|
||||
## Downloads
|
||||
|
||||
@@ -242,6 +280,8 @@ and web backend defaults are unchanged.
|
||||
| Fedora/RHEL/openSUSE | `cadcraft-<ver>-linux-x86_64.rpm` | `cadcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `cadcraft-<ver>-linux-x86_64.tar.gz` | `cadcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V (riscv64): `cadcraft-<ver>-linux-riscv64.tar.gz`, cross-compiled; needs glibc 2.39 or newer (Ubuntu 24.04+, Debian 13+).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|
||||
+128
-83
@@ -1,98 +1,143 @@
|
||||
# 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.
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
## At a glance
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| **How close is an alpha?** | **≈ 75% of the way.** About **50 Opus 5.5 wall-clock hours** remain (≈ 15–20 hours elapsed with 3–4 agents in parallel). |
|
||||
| **How far is 100% AutoCAD parity?** | **≈ 29% parity overall** (weighted by how much each area matters). About **570 Opus 5.5 wall-clock hours** remain (≈ 150–190 hours elapsed in parallel). |
|
||||
| **2D drafting parity** (what most AutoCAD users do every day: M1–M8) | **≈ 55%.** About **245 hours** remain. |
|
||||
| **The biggest single gap** | 3D modelling (M11): solids, surfaces, meshes, visual styles and rendering — ≈ 200 hours on its own. |
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
### What "alpha" means here
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model, is always R2000 and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
|
||||
A person can do real 2D drafting work in CADCraft and trust it with their files:
|
||||
## Headline numbers
|
||||
|
||||
- Installable, signed builds for macOS, Windows, Linux, FreeBSD and the web from the release pipeline.
|
||||
- Drawings from AutoCAD open correctly, and CADCraft's DXF/DWG files open cleanly in AutoCAD and other readers.
|
||||
- Every common 2D draw, modify, annotate, layer, block, layout and plot command works, both with the mouse and at the command line.
|
||||
- No crashes and no lost work: autosave and recovery, plus undo that always works.
|
||||
|
||||
### Alpha checklist
|
||||
|
||||
| Item | State | Hours left |
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2D draw and modify commands, snaps, tracking, grips | done (minor options missing) | — |
|
||||
| Dimensions, text, MTEXT, leaders, tables, TrueType fonts | done | — |
|
||||
| Hatch with pick points, blocks, attributes | done | — |
|
||||
| Layouts, viewports, MSPACE/PSPACE, page setup, PLOT to PDF | done | — |
|
||||
| Parametric constraints, constraint bars and dimensional constraints on the canvas, Parameters Manager | done | — |
|
||||
| DXF/DWG round trip of everything above | done for our data; MULTILEADER is written as LEADER + MTEXT; `*D` dimension blocks duplicate on re-save | 6 |
|
||||
| Confirmed opening in AutoCAD (black-box check) | not yet confirmed | 4 |
|
||||
| Layer Properties Manager (VP overrides, states, filters), QSELECT, Quick Properties | done | — |
|
||||
| Autosave, crash recovery, RECOVER/AUDIT | partial | 6 |
|
||||
| Options dialog and preferences | partial | 6 |
|
||||
| Plot preview | missing | 4 |
|
||||
| Scripted smoke test of all 290 commands through the UI, then fixing what it finds | not started | 12 |
|
||||
| Release pipeline: first green signed build on every platform | workflows written; first run being started | 6 |
|
||||
| Getting-started docs and the web build hosted | partial | 4 |
|
||||
| **Total** | | **≈ 50** |
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Ready for real work | **≈ 38%** | estimated ([target-app-parity.md](docs/target-app-parity.md)) |
|
||||
| Mainstream practitioner (2D drafter) | **≈ 28%** (lower than the full number: file exchange, stability and interaction discounts multiply) | estimated ([target-app-parity.md](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Essentials user | **≈ 46%** | estimated ([target-app-parity.md](docs/target-app-parity.md#essentials-user)) |
|
||||
| Remaining to alpha | **≈ 125–190 h** (the gate rows) | estimated |
|
||||
| Remaining to beta | **≈ 600–930 h** (≈ 150–250 h elapsed with 4–6 agents) | estimated |
|
||||
| Remaining to full parity | **≈ 1,300–2,100 h** (≈ 330–550 h elapsed) | estimated |
|
||||
|
||||
## Where we are
|
||||
### Readiness by audience
|
||||
|
||||
| Measure | Value |
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---:|---|---|
|
||||
| Full target (ready for real work) | ≈ 38% | ≈ 1,150–1,850 h (≈ 70% parallelises across 4–6 agents: ≈ 300–480 h elapsed) | 3D kernel (300–450 h), DWG/DXF and other formats, AutoLISP and ecosystem, blocks/xrefs/dynamic blocks, localization |
|
||||
| Mainstream practitioner (2D drafter) | ≈ 28% | ≈ 570–880 h (≈ 70% parallelises: ≈ 150–240 h elapsed) | lossless DWG exchange and a real-file corpus, precision input (tracking, dynamic input), 2D geometry precision, blocks/xrefs, annotation depth, stability backlog |
|
||||
| Essentials user | ≈ 46% | ≈ 140–225 h (≈ 50% parallelises across 3 agents: ≈ 60–110 h elapsed) | stubbed options in basic draw/modify commands, opening DWGs people send, launch/stability fixes, plot dialog, context menus and dynamic input |
|
||||
|
||||
Hours are subsets (essentials ⊂ mainstream ⊂ full) and use the calibration in [target-app-parity.md](docs/target-app-parity.md#remaining-effort-and-how-it-was-calibrated): ≈ 25 commands
|
||||
per agent-hour at presence quality in the first build (≈ 11.5 agent-hours), 0.3–0.7 h per option
|
||||
or bug fix (≈ 60 such fixes on 2026-10-10), 2–5 h per moderate feature, 15–100 h per subsystem.
|
||||
|
||||
Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build: the first version
|
||||
(51k lines, 290 commands) took ≈ 11.5 agent-hours at presence quality, while depth work runs at
|
||||
0.3–0.7 h per option or bug fix (≈ 60 such fixes landed on 2026-10-10). Details:
|
||||
[target-app-parity.md](docs/target-app-parity.md#remaining-effort-and-how-it-was-calibrated).
|
||||
|
||||
## By dimension
|
||||
|
||||
| Dimension | Parity | Remaining (h) | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (depth, weighted by use) | 41% | 735–1,115 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area-the-features-dimension) |
|
||||
| UI/UX fidelity | 43% | 100–160 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 30% | 170–280 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware | 45% | 15–25 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | 5% | 60–100 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | 60% | 20–40 | [hardware-parity.md](docs/hardware-parity.md#performance-on-hardware-internal-numbers-only) |
|
||||
| Stability | 35% | 30–50 | [gaps.md](docs/gaps.md#stability) |
|
||||
| Platforms | 75% | 15–25 | [gaps.md](docs/gaps.md#platforms) |
|
||||
| Ecosystem and automation | 10% | 120–200 | [gaps.md](docs/gaps.md#ecosystem-and-automation) |
|
||||
| AI features | 20% | 40–80 | [gaps.md](docs/gaps.md#ai-features) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Parity | Remaining (h) | Doc |
|
||||
|---|---:|---|---|
|
||||
| Draw (2D) | 60% | 15–25 | [gaps.md](docs/gaps.md#features) |
|
||||
| Modify | 50% | 40–60 | [gaps.md](docs/gaps.md#features) |
|
||||
| Geometry precision | 40% | 70–110 | [geometry-parity.md](docs/geometry-parity.md) |
|
||||
| Layers and properties | 70% | 10–15 | [gaps.md](docs/gaps.md#features) |
|
||||
| Dimensions | 45% | 25–35 | [gaps.md](docs/gaps.md#features) |
|
||||
| Text and MTEXT | 35% | 30–45 | [gaps.md](docs/gaps.md#features) |
|
||||
| Leaders, tables, fields | 25% | 25–35 | [gaps.md](docs/gaps.md#features) |
|
||||
| Hatch and gradients | 35% | 15–25 | [gaps.md](docs/gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 25% | 80–120 | [gaps.md](docs/gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 35% | 35–50 | [gaps.md](docs/gaps.md#features) |
|
||||
| Inquiry and utilities | 30% | 30–45 | [gaps.md](docs/gaps.md#features) |
|
||||
| Parametric constraints | 50% | 20–30 | [geometry-parity.md](docs/geometry-parity.md) |
|
||||
| 3D modelling and rendering | 2% | 300–450 | [gaps.md](docs/gaps.md#features) |
|
||||
| Collaboration and cloud | 5% | 40–70 | [gaps.md](docs/gaps.md#features) |
|
||||
|
||||
## Languages
|
||||
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English only. Detail:
|
||||
[localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Status | UI translated |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | none | 0% |
|
||||
| Spanish | none | 0% |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | none | 0% |
|
||||
| French | none | 0% |
|
||||
| Portuguese | none | 0% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| German | none | 0% |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Other languages shipped: Ukrainian, partial (936 catalog rows, landed with #36; engine messages stay English).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. Gate and land: fix fmt on main, run `cargo xtask ci` on every PR, land the 47 open PRs (15–25 h).
|
||||
2. Stop losing data on save; native MULTILEADER; save DWG/DXF at a chosen version (30–45 h).
|
||||
3. Real-file DWG/DXF corpus and a black-box check in AutoCAD (10–15 h + owner).
|
||||
4. Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input (25–35 h).
|
||||
5. Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER (15–25 h).
|
||||
6. Autosave, recovery, AUDIT, RECOVER (10–15 h).
|
||||
7. Exact ellipse/spline geometry and correct polyline offsets (30–50 h).
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **233 / 491 reference menu items (47%)** |
|
||||
| Registered commands | 290 (133 with interactive prompts) |
|
||||
| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 29%** |
|
||||
| Performance | 200k entities: pick 0.001 ms, pick after an edit ≈ 4 ms (incremental R-tree), GPU canvas ≈ 4 ms per frame |
|
||||
| Tests | geometry, colour, document, fonts, render, DXF/DWG round trips, constraints solver, 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 |
|
||||
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
|
||||
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. |
|
||||
| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. |
|
||||
| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. |
|
||||
| 2026-10-07 | First version built in one session (≈ 6.9 h elapsed, ≈ 11.5 agent-hours): M0 vertical slice, dimensions, hatch, blocks, DWG bridge, layouts and PDF plotting, GPU canvas, R-tree, constraints, Layer Properties Manager, QSELECT, release pipeline. |
|
||||
|
||||
Menu breadth counts menu items that exist; the weighted parity estimate also counts depth (options,
|
||||
edge cases, dialogs), which is why it is lower.
|
||||
## Revision history
|
||||
|
||||
## Milestones
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
|
||||
|
||||
| # | 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 | ~85% (all 2D Draw items exist) | 8 |
|
||||
| M2 | Modify: grips (stretch/move/rotate/scale/mirror, multifunctional), PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curve types, MATCHPROP UI, Quick Properties | ~65% (PEDIT, LENGTHEN, ALIGN, BLEND, grips API, TRIM on splines) | 18 |
|
||||
| M3 | Layers & properties: full Layer Properties Manager (filters, VP overrides, states), linetype manager, lineweight display, transparency, QSELECT dialog, Properties for every object type | ~75% (Layer Properties Manager with VP overrides/states/filters, QSELECT, Quick Properties) | 10 |
|
||||
| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~50% (TrueType text, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; DXF round trip; not yet: native MULTILEADER objects, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 30 |
|
||||
| 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 | ~35% (pick-point boundaries with islands, gradients, BLOCK/INSERT, attributes, nested blocks) | 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 | ~55% (layouts, viewports, MSPACE/PSPACE through viewports, page setup, PLOT/EXPORTPDF) | 25 |
|
||||
| M7 | Files: DWG read/write (done via acadrust), DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~55% (DXF round trip of dimension styles, associativity, tables and constraints) | 55 |
|
||||
| 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) | ~60% (own solver crate, all GC*/DC* commands, AUTOCONSTRAIN, PARAMETERS, conflict detection, re-solve after edits, DXF persistence; not yet: inference while drawing, Smooth as true G2) | 12 |
|
||||
| M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~70% (GPU canvas, incremental R-tree: pick 50 ms → 0.001 ms at 200k entities, ≈ 4 ms after an edit) | 12 |
|
||||
| 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** | | **≈ 570 hours** |
|
||||
## Shared UI workspace integration
|
||||
|
||||
At roughly 570 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to
|
||||
about 150–190 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity
|
||||
(M1–M8) is the first ≈ 250 hours.
|
||||
|
||||
## Current focus
|
||||
|
||||
The drafting and layer-palette icon-button helper uses shared `craft-ui` interaction and accessibility,
|
||||
with CADCraft's original multicolor painting and disabled appearance. This does not change drafting
|
||||
commands. The native host now restores workspace visibility, toolset choices and collapsed groups;
|
||||
malformed preference files are reported and preserved. Painted toolbar and document/toolset/layout
|
||||
tabs expose keyboard focus and labels, and the control protocol can observe Tab/Shift-Tab traversal.
|
||||
Compact windows keep toolbar command groups accessible through a More menu, while coordinates and
|
||||
transient messages share an ellipsized readout that does not cover layout names.
|
||||
|
||||
1. Finish the alpha checklist above (release builds, AutoCAD open check, smoke test of every command, autosave/recovery, plot preview).
|
||||
2. Layer filter groups, constraint bar polish (close buttons, hover highlight).
|
||||
3. Native MULTILEADER objects in DXF; stop `*D` block duplication on re-save.
|
||||
4. Then 3D foundations (UCS, orbit, solids via truck/csgrs per plan/adr/0001).
|
||||
|
||||
- **2026-10-10 (UI docking):** Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change.
|
||||
Tool Sets, Layers and Properties use shared docking with persisted layouts, keyboard focus and compact toolbar overflow. No milestone percentage change.
|
||||
@@ -182,6 +182,12 @@ fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(),
|
||||
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
if !cadcraft_io::is_image(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
|
||||
eprintln!("{m}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,12 @@ egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# File > Open… and dropped files (src/upload.rs): the browser's file picker and file reads.
|
||||
[target.wasm32-unknown-unknown.dependencies]
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["persistence", "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"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url"] }
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Browser downloads: hand bytes to the user as a file, through web-sys only (a `Blob`, an object
|
||||
//! URL and a temporary `<a download>` link). This is the web's file route for everything that
|
||||
//! produces a file: drawing saves today (installed as the engine's
|
||||
//! [`cadcraft_engine::cmd::file::set_deliver`] hook), plots next.
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use web_sys::js_sys;
|
||||
|
||||
/// How long the object URL stays valid after the click; browsers read it asynchronously.
|
||||
const REVOKE_AFTER_MS: i32 = 60_000;
|
||||
|
||||
/// Start a browser download of `bytes` as a file called `name` with MIME type `mime`.
|
||||
///
|
||||
/// `Ok` once the download was triggered. Where it lands (or whether the user cancels a "save as"
|
||||
/// prompt of the browser) is up to the browser and can't be observed from the page.
|
||||
pub fn download(name: &str, mime: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("a file name is required".into());
|
||||
}
|
||||
let window = web_sys::window().ok_or("no browser window")?;
|
||||
let document = window.document().ok_or("no browser document")?;
|
||||
let body = document.body().ok_or("the page has no body")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let options = web_sys::BlobPropertyBag::new();
|
||||
options.set_type(mime);
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_error)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_error)?;
|
||||
if let Err(e) = click_link(&document, &body, &url, name) {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
return Err(e);
|
||||
}
|
||||
let later = url.clone();
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&later);
|
||||
});
|
||||
if window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_AFTER_MS).is_err() {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn click_link(document: &web_sys::Document, body: &web_sys::HtmlElement, url: &str, name: &str) -> Result<(), String> {
|
||||
let link = document
|
||||
.create_element("a")
|
||||
.map_err(js_error)?
|
||||
.dyn_into::<web_sys::HtmlAnchorElement>()
|
||||
.map_err(|_| "could not create a download link".to_string())?;
|
||||
link.set_href(url);
|
||||
link.set_download(name);
|
||||
link.set_attribute("style", "display:none").map_err(js_error)?;
|
||||
body.append_child(&link).map_err(js_error)?;
|
||||
link.click();
|
||||
link.remove();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn js_error(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
//! 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.
|
||||
//! drawing. Saving downloads the drawing through the browser (`download.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod download;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use cadcraft_engine::Session;
|
||||
@@ -15,12 +18,26 @@ mod web {
|
||||
const CANVAS_ID: &str = "cadcraft_canvas";
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
struct Shell(CadApp);
|
||||
/// The app, and the files File > Open… and drag and drop read for it in the background.
|
||||
struct Shell(CadApp, crate::upload::Inbox);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
@@ -30,6 +47,12 @@ mod web {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
|
||||
fn services() -> Services {
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
|
||||
@@ -37,6 +60,8 @@ mod web {
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
// No file system here: saved files reach the user as browser downloads.
|
||||
cadcraft_engine::cmd::file::set_deliver(crate::download::download);
|
||||
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::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
@@ -54,14 +79,27 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let mut app = CadApp::new(Session::new(), services());
|
||||
app.system_languages =
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
let _ = app.run("ui.sample", serde_json::json!({}));
|
||||
}
|
||||
Ok(Box::new(Shell(app)))
|
||||
// No paths on the web: OPEN asks the browser's file picker, which reads the
|
||||
// chosen file in the background (`upload.rs`), so the picker returns none.
|
||||
let inbox = crate::upload::Inbox::new(&cc.egui_ctx);
|
||||
let picker = inbox.clone();
|
||||
app.services.pick_open = Some(Box::new(move || {
|
||||
picker.pick();
|
||||
None
|
||||
}));
|
||||
Ok(Box::new(Shell(app, inbox)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -77,6 +115,9 @@ mod web {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod upload;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Browser uploads: File > Open… on the web, through web-sys only. There is no file system to pick
|
||||
//! a path from, so the picker is a hidden `<input type="file">`; the chosen file (or one dropped on
|
||||
//! the page) is read in the background and handed to the app on its next frame, which opens it
|
||||
//! with OPEN `{data, name}` ([`CadApp::open_bytes`]).
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use cadcraft_ui_egui::CadApp;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use web_sys::js_sys;
|
||||
|
||||
/// The hidden file input (one at a time; a cancelled pick leaves it until the next one).
|
||||
const INPUT_ID: &str = "cadcraft_open_input";
|
||||
/// What the picker offers: the formats OPEN reads.
|
||||
const ACCEPT: &str = ".dxf,.dwg";
|
||||
|
||||
/// A file read in the browser (name, contents), or why it couldn't be read.
|
||||
type Upload = Result<(String, Vec<u8>), String>;
|
||||
|
||||
/// Files read in the background, waiting for the app's next frame.
|
||||
#[derive(Clone)]
|
||||
pub struct Inbox {
|
||||
files: Rc<Cell<Vec<Upload>>>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(ctx: &egui::Context) -> Self {
|
||||
Inbox { files: Rc::default(), ctx: ctx.clone() }
|
||||
}
|
||||
|
||||
/// Show the browser's file picker and return at once. A chosen file arrives through
|
||||
/// [`Self::drain`]; cancelling the picker does nothing.
|
||||
pub fn pick(&self) {
|
||||
if let Err(e) = self.show_picker() {
|
||||
self.push(Err(format!("Open: the browser's file picker is not available ({e})")));
|
||||
}
|
||||
}
|
||||
|
||||
fn show_picker(&self) -> Result<(), String> {
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no browser document")?;
|
||||
let body = document.body().ok_or("the page has no body")?;
|
||||
if let Some(old) = document.get_element_by_id(INPUT_ID) {
|
||||
old.remove();
|
||||
}
|
||||
let input = document
|
||||
.create_element("input")
|
||||
.map_err(js_error)?
|
||||
.dyn_into::<web_sys::HtmlInputElement>()
|
||||
.map_err(|_| "could not create a file input".to_string())?;
|
||||
input.set_type("file");
|
||||
input.set_accept(ACCEPT);
|
||||
input.set_id(INPUT_ID);
|
||||
input.set_attribute("style", "display:none").map_err(js_error)?;
|
||||
let (inbox, chosen) = (self.clone(), input.clone());
|
||||
// Called once, when a file was chosen (browsers don't fire `change` on cancel).
|
||||
let on_change = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
if let Some(file) = chosen.files().and_then(|f| f.get(0)) {
|
||||
inbox.read(file);
|
||||
}
|
||||
chosen.remove();
|
||||
});
|
||||
input.set_onchange(Some(on_change.unchecked_ref()));
|
||||
body.append_child(&input).map_err(js_error)?;
|
||||
input.click();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
|
||||
pub fn take_dropped(&self, ctx: &egui::Context) {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
if let Some(file) = f.web_file() {
|
||||
self.read(file.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `file` in the background; it arrives through [`Self::drain`]. A file too large to
|
||||
/// open is refused before it is read.
|
||||
fn read(&self, file: web_sys::File) {
|
||||
let name = file.name();
|
||||
// `File.size` is a whole number of bytes; anything else counts as too large.
|
||||
let size = file.size();
|
||||
let len = if size.is_finite() && size >= 0.0 { size as u64 } else { u64::MAX };
|
||||
if let Some(e) = CadApp::open_size_error(&name, len) {
|
||||
return self.push(Err(e));
|
||||
}
|
||||
let inbox = self.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())),
|
||||
Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))),
|
||||
};
|
||||
inbox.push(r);
|
||||
});
|
||||
}
|
||||
|
||||
fn push(&self, upload: Upload) {
|
||||
let mut files = self.files.take();
|
||||
files.push(upload);
|
||||
self.files.set(files);
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Open what arrived since the last frame (or report why it couldn't be read).
|
||||
pub fn drain(&self, app: &mut CadApp) {
|
||||
for upload in self.files.take() {
|
||||
match upload {
|
||||
Ok((name, bytes)) => app.open_bytes(&name, &bytes),
|
||||
Err(e) => app.open_failed(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn js_error(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
@@ -22,8 +22,12 @@ log = { workspace = true }
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
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}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("cadcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
@@ -42,12 +42,14 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
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);
|
||||
let (req, pending) = 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"}));
|
||||
// On timeout the request is cancelled if the app hasn't started it, and the reply's
|
||||
// `state` says whether it can still have run.
|
||||
let mut r = pending.wait(Duration::from_secs(60));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app
|
||||
//! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no
|
||||
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/cadcraft.log`
|
||||
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`).
|
||||
//! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a
|
||||
//! run that crashed survives the next launch.
|
||||
//!
|
||||
//! Levels: `info` for CADCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,cadcraft_io=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`cadcraft*=debug`). `RUST_LOG=cadcraft_engine=debug` also logs every
|
||||
//! command-line message (`Session::echo`).
|
||||
//!
|
||||
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink. A record takes the sink lock only for one
|
||||
//! `write_all` of an already formatted line (standard error is written outside the lock).
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "cadcraft.log";
|
||||
/// The log directory inside the settings directory.
|
||||
pub const LOG_DIR: &str = "logs";
|
||||
/// How many previous logs are kept (`cadcraft.1.log` … `cadcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,cadcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("cadcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// No file will come: drop the kept lines and keep none from now on.
|
||||
pub fn close(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.stderr_only();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no settings directory, or it can't be used): stop keeping records for one.
|
||||
pub fn stderr_only(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Format before taking the lock, so the lock covers one write only.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("cadcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_cadcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("cadcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("cadcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("cadcraft_ui_egui::gpu"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("cadcraft_engine"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,cadcraft_io=trace,cadcraft_io::dxf_ext=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("cadcraft_io::svg"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("cadcraft_io::dxf_ext"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,cadcraft_ui*=debug").level_for("cadcraft_ui_egui::gpu"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,cadcraft_ui*=debug").level_for("cadcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"cadcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,cadcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["cadcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,cadcraft=DEBUG").level_for("cadcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("cadcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("cadcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("cadcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "cadcraft", "CADCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "cadcraft_engine", "a command-line echo");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("cadcraft: CADCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("cadcraft_ui_egui::gpu").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("cadcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "cadcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("cadcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_only_stops_keeping_records_for_a_file() {
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "cadcraft", "before");
|
||||
logger.stderr_only();
|
||||
record(&logger, log::Level::Info, "cadcraft", "after");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "no memory is held for a file that will not come");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "cadcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+157
-12
@@ -8,18 +8,22 @@
|
||||
//!
|
||||
//! `CADCRAFT_VSYNC=1` presents with vsync on Linux/BSD, where the default is the low-latency
|
||||
//! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`.
|
||||
//!
|
||||
//! `log` records go to standard error and `<settings dir>/logs/cadcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`] and [`config_dir`].
|
||||
#![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(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -57,12 +61,13 @@ impl eframe::App for App {
|
||||
}
|
||||
self.cad.logic(ctx);
|
||||
self.persist_workspace(false);
|
||||
if self.cad.quit_requested {
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.cad.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
@@ -93,23 +98,56 @@ fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Drawing (DXF)", &["dxf"])
|
||||
.add_filter("All files", &["*"])
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf"])
|
||||
.add_filter(i18n::t("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"])
|
||||
filters_for_save(name)
|
||||
.iter()
|
||||
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// File types for the Save dialog, the suggested name's type first: Windows adds the first
|
||||
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
|
||||
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
|
||||
/// then defaults to DWG; other names keep DXF first.
|
||||
fn filters_for_save(name: &str) -> [(&'static str, &'static str); 5] {
|
||||
let mut filters = [("Drawing (DXF)", "dxf"), ("Drawing (DWG)", "dwg"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")];
|
||||
let lower = name.to_ascii_lowercase();
|
||||
filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}")));
|
||||
filters
|
||||
}
|
||||
|
||||
/// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else
|
||||
/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs,
|
||||
/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
config_dir_from(|k| std::env::var_os(k))
|
||||
}
|
||||
|
||||
/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable
|
||||
/// counts as unset, so a blank value never means the working directory.
|
||||
fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<std::path::PathBuf> {
|
||||
let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
if let Some(dir) = set("CADCRAFT_CONFIG_DIR") {
|
||||
return Some(dir);
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
set("HOME").map(|h| h.join("Library/Application Support/CADCraft"))
|
||||
} else if cfg!(windows) {
|
||||
set("APPDATA").map(|a| a.join("CADCraft"))
|
||||
} else {
|
||||
// The XDG spec ignores a relative XDG_CONFIG_HOME.
|
||||
set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft"))
|
||||
}
|
||||
}
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -126,14 +164,29 @@ fn version_string() -> String {
|
||||
format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// The control port from `--control <port>` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is
|
||||
/// not a port is logged and ignored instead of silently starting no control channel.
|
||||
fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
|
||||
let value = value?;
|
||||
let port = value.trim().parse().ok();
|
||||
if port.is_none() {
|
||||
log::warn!("{source}: {value:?} is not a port number; the control channel is off");
|
||||
}
|
||||
port
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let mut control_port: Option<u16> = std::env::var("CADCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let mut control_port =
|
||||
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
|
||||
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()),
|
||||
"--control" => control_port = control_port_from("--control", args.next()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("{}", version_string());
|
||||
@@ -142,6 +195,18 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
|
||||
Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)");
|
||||
}
|
||||
}
|
||||
}
|
||||
install_io();
|
||||
let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos"))));
|
||||
let mut options = eframe::NativeOptions {
|
||||
@@ -166,6 +231,16 @@ fn main() -> eframe::Result {
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"CADCraft",
|
||||
options,
|
||||
@@ -179,6 +254,7 @@ fn main() -> eframe::Result {
|
||||
eprintln!("CADCraft workspace: {error}");
|
||||
app.set_status(error);
|
||||
}
|
||||
app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect());
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
@@ -212,3 +288,72 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{config_dir_from, control_port_from, filters_for_save};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
|
||||
let first = |name: &str| filters_for_save(name)[0].1;
|
||||
assert_eq!(first("Waco Winnelson Floorplan.dwg"), "dwg");
|
||||
assert_eq!(first("FLOORPLAN.DWG"), "dwg");
|
||||
// New drawings and DXF files keep DXF first.
|
||||
assert_eq!(first("Drawing1.dxf"), "dxf");
|
||||
assert_eq!(first("Drawing1"), "dxf");
|
||||
assert_eq!(first("plan.pdf"), "pdf");
|
||||
for name in ["a.dwg", "b.dxf", "c"] {
|
||||
let exts: Vec<_> = filters_for_save(name).iter().map(|(_, e)| *e).collect();
|
||||
assert!(exts.contains(&"dwg") && exts.contains(&"dxf"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_values_that_are_not_ports_are_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979));
|
||||
assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979));
|
||||
assert_eq!(control_port_from("--control", None), None);
|
||||
for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] {
|
||||
assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_override_variable_wins_and_empty_values_are_unset() {
|
||||
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
|
||||
assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc")));
|
||||
let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")];
|
||||
assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory");
|
||||
assert_eq!(config_dir_from(env(&[])), None);
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn linux_and_bsd_follow_xdg_config_home() {
|
||||
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft")));
|
||||
assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
|
||||
// The XDG spec says a relative XDG_CONFIG_HOME is ignored.
|
||||
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_uses_application_support() {
|
||||
assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_uses_appdata() {
|
||||
assert_eq!(
|
||||
config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])),
|
||||
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use cadcraft_ui_egui::CadApp;
|
||||
use cadcraft_ui_egui::menus::{self, Entry};
|
||||
use cadcraft_ui_egui::{CadApp, i18n};
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
items: HashMap<String, (String, MenuItem)>,
|
||||
items: HashMap<String, (String, String, MenuItem)>,
|
||||
submenus: Vec<(String, Submenu)>,
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
last_refresh: f64,
|
||||
}
|
||||
|
||||
@@ -27,37 +30,42 @@ impl NativeMenu {
|
||||
pub fn install(app: &mut CadApp) -> Self {
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let language = app.language();
|
||||
let mut submenus = Vec::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 about = MenuItem::with_id("cc-about", i18n::tr(language, "About CADCraft"), true, None);
|
||||
let discord = MenuItem::with_id("cc-discord", i18n::tr(language, "Join the ArtCraft Discord…"), true, None);
|
||||
let predefined = vec![
|
||||
("Services", PredefinedMenuItem::services(Some(i18n::tr(language, "Services")))),
|
||||
("Hide CADCraft", PredefinedMenuItem::hide(Some(i18n::tr(language, "Hide CADCraft")))),
|
||||
("Hide Others", PredefinedMenuItem::hide_others(Some(i18n::tr(language, "Hide Others")))),
|
||||
("Show All", PredefinedMenuItem::show_all(Some(i18n::tr(language, "Show All")))),
|
||||
("Quit CADCraft", PredefinedMenuItem::quit(Some(i18n::tr(language, "Quit CADCraft")))),
|
||||
];
|
||||
let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator()]);
|
||||
for (label, item) in &predefined {
|
||||
let _ = app_menu.append(item);
|
||||
if ["Services", "Show All"].contains(label) {
|
||||
let _ = app_menu.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
}
|
||||
items.insert("cc-about".into(), ("ui.dialog.about".into(), "About CADCraft".into(), about));
|
||||
items.insert("cc-discord".into(), ("ui.discord".into(), "Join the ArtCraft 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 sub = Submenu::new(i18n::tr(language, &title), true);
|
||||
build(&sub, &entries, &mut items, &mut submenus, &mut counter, language);
|
||||
let _ = menu.append(&sub);
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self { _menu: menu, items, last_refresh: 0.0 }
|
||||
Self { _menu: menu, items, submenus, predefined, language, 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 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 {
|
||||
@@ -66,32 +74,53 @@ impl NativeMenu {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
let language = app.language();
|
||||
if self.language != language {
|
||||
self.language = language;
|
||||
for (_, label, item) in self.items.values() {
|
||||
item.set_text(i18n::tr(language, label));
|
||||
}
|
||||
for (label, item) in &self.predefined {
|
||||
item.set_text(i18n::tr(language, label));
|
||||
}
|
||||
for (label, submenu) in &self.submenus {
|
||||
submenu.set_text(i18n::tr(language, label));
|
||||
}
|
||||
}
|
||||
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() {
|
||||
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<String, (String, MenuItem)>, counter: &mut usize) {
|
||||
fn build(
|
||||
parent: &Submenu,
|
||||
entries: &[Entry],
|
||||
items: &mut HashMap<String, (String, String, MenuItem)>,
|
||||
submenus: &mut Vec<(String, Submenu)>,
|
||||
counter: &mut usize,
|
||||
language: &str,
|
||||
) {
|
||||
for e in entries {
|
||||
match e {
|
||||
Entry::Sub { label, children } => {
|
||||
let sub = Submenu::new(label, true);
|
||||
build(&sub, children, items, counter);
|
||||
let sub = Submenu::new(i18n::tr(language, label), true);
|
||||
build(&sub, children, items, submenus, counter, language);
|
||||
let _ = parent.append(&sub);
|
||||
submenus.push((label.clone(), 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 i = MenuItem::with_id(mid.clone(), i18n::tr(language, label), *enabled, shortcut.as_deref().and_then(accel));
|
||||
let _ = parent.append(&i);
|
||||
items.insert(mid, (id.clone(), i));
|
||||
items.insert(mid, (id.clone(), label.clone(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,10 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Tru10]
|
||||
display_name = "by Truitt"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
+14
-11
@@ -208,16 +208,16 @@ pub fn nearest_aci(c: Rgb) -> u8 {
|
||||
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 {
|
||||
/// Display colour of `c` on `background`. Colour 7 (`aci7`) flips to contrast with the
|
||||
/// background, white on dark and black on light (paper), as CAD programs draw it; every other
|
||||
/// colour, including a true-colour or index white or black, keeps its RGB.
|
||||
pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb {
|
||||
if !aci7 {
|
||||
c
|
||||
} else if background.luma() < 0.5 {
|
||||
Rgb(255, 255, 255)
|
||||
} else {
|
||||
Rgb(0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,10 @@ mod tests {
|
||||
|
||||
#[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));
|
||||
assert_eq!(display_rgb(Rgb(255, 255, 255), true, Rgb(255, 255, 255)), Rgb(0, 0, 0));
|
||||
assert_eq!(display_rgb(Rgb(255, 255, 255), true, Rgb(33, 40, 48)), Rgb(255, 255, 255));
|
||||
// Only colour 7 flips: ACI 255 / true-colour white and dark index colours keep their RGB.
|
||||
assert_eq!(display_rgb(Rgb(255, 255, 255), false, Rgb(255, 255, 255)), Rgb(255, 255, 255));
|
||||
assert_eq!(display_rgb(aci_rgb(250), false, Rgb(33, 40, 48)), aci_rgb(250));
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,10 @@ impl Built {
|
||||
return need("two lines or an arc");
|
||||
}
|
||||
b.target = target.to_radians();
|
||||
if !b.target.is_finite() || b.target.abs() > std::f64::consts::TAU {
|
||||
// Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger
|
||||
// target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn.
|
||||
let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU };
|
||||
if !b.target.is_finite() || b.target.abs() > max {
|
||||
return Err("angle out of range".into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,19 @@ fn symmetric_fix() {
|
||||
ok(&mut d, con(ConstraintKind::Symmetric, &[w(p), w(q), w(axis)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_line_angle_over_180_is_rejected() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
|
||||
let b = line(&mut d, (0.0, 0.0), (0.0, 5.0));
|
||||
let before = (ln(&d, a), ln(&d, b));
|
||||
assert!(add(&mut d, dim(ConstraintKind::Angular, &[w(a), w(b)], "", "200"), &SolveOptions::default()).is_err());
|
||||
assert!(d.constraints.is_empty());
|
||||
assert_eq!((ln(&d, a), ln(&d, b)), before);
|
||||
ok(&mut d, dim(ConstraintKind::Angular, &[w(a), w(b)], "", "180"));
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensional_constraints() {
|
||||
let mut d = Drawing::default();
|
||||
|
||||
+150
-8
@@ -94,6 +94,9 @@ pub struct Common {
|
||||
pub visible: bool,
|
||||
pub thickness: f64,
|
||||
pub extrusion: Vec3,
|
||||
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
|
||||
/// style table.
|
||||
pub plot_style: String,
|
||||
}
|
||||
|
||||
impl Default for Common {
|
||||
@@ -108,6 +111,7 @@ impl Default for Common {
|
||||
visible: true,
|
||||
thickness: 0.0,
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +220,17 @@ pub struct Text {
|
||||
#[serde(default)]
|
||||
pub valign: VAlign,
|
||||
}
|
||||
impl Text {
|
||||
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
|
||||
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
|
||||
/// aligned and fit text.
|
||||
pub fn justify_point(&self) -> Vec2 {
|
||||
match (self.halign, self.valign) {
|
||||
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
|
||||
_ => self.align_pt.unwrap_or(self.insert).xy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
@@ -241,6 +256,10 @@ pub struct MText {
|
||||
pub contents: String,
|
||||
#[serde(default = "one")]
|
||||
pub line_spacing: f64,
|
||||
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
|
||||
/// heights; At least (`false`, the default) lets taller characters push lines apart.
|
||||
#[serde(default)]
|
||||
pub line_spacing_exact: bool,
|
||||
}
|
||||
fn attach_tl() -> u8 {
|
||||
1
|
||||
@@ -256,7 +275,43 @@ pub struct Attrib {
|
||||
pub constant: bool,
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
/// An attribute's own properties; each one left unset follows the attribute definition.
|
||||
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
|
||||
pub props: AttribProps,
|
||||
}
|
||||
|
||||
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
|
||||
/// lineweight), overriding those of its definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AttribProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Color>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<Lineweight>,
|
||||
}
|
||||
|
||||
impl AttribProps {
|
||||
/// Nothing set: the attribute is drawn with its definition's properties.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == AttribProps::default()
|
||||
}
|
||||
/// `base` (the definition's properties) with the ones set here in place.
|
||||
pub fn over(&self, base: &Common) -> Common {
|
||||
Common {
|
||||
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
|
||||
color: self.color.unwrap_or(base.color),
|
||||
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
|
||||
lineweight: self.lineweight.unwrap_or(base.lineweight),
|
||||
..base.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Insert {
|
||||
@@ -409,6 +464,26 @@ pub struct MLeader {
|
||||
pub style: String,
|
||||
#[serde(default = "one")]
|
||||
pub arrow_size: f64,
|
||||
/// Leader lines are smooth curves through their points (leader type Spline). An empty
|
||||
/// `leaders` is a multileader without a leader line (leader type None).
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
/// Block content in place of the text: a block reference placed at the landing.
|
||||
#[serde(default)]
|
||||
pub block: Option<Insert>,
|
||||
}
|
||||
|
||||
impl MLeader {
|
||||
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
|
||||
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
|
||||
self.leaders
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
|
||||
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
/// A hatch boundary loop: closed polyline with bulges.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -441,6 +516,10 @@ pub struct Hatch {
|
||||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// The pattern's line families as a file defined them, for a pattern that isn't in the
|
||||
/// standard library (unit scale and angle, like the library's; empty otherwise).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pattern_lines: Vec<crate::library::PatternLine>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -491,10 +570,68 @@ pub struct Viewport {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Image {
|
||||
pub insert: Vec3,
|
||||
/// One pixel's width (U) and height (V) vectors in drawing units.
|
||||
pub u: Vec3,
|
||||
pub v: Vec3,
|
||||
/// Image size in pixels.
|
||||
pub size: Vec2,
|
||||
/// The image file (its IMAGEDEF object's path).
|
||||
pub path: String,
|
||||
/// The image definition's name (its key in the drawing's image dictionary).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
|
||||
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub clip: Vec<Vec2>,
|
||||
/// The clip boundary is applied.
|
||||
#[serde(default)]
|
||||
pub clipping: bool,
|
||||
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
|
||||
/// clip boundary, 8 transparency on.
|
||||
#[serde(default = "image_display")]
|
||||
pub display: u16,
|
||||
/// Brightness, contrast and fade, 0..=100.
|
||||
#[serde(default = "image_fifty")]
|
||||
pub brightness: u8,
|
||||
#[serde(default = "image_fifty")]
|
||||
pub contrast: u8,
|
||||
#[serde(default)]
|
||||
pub fade: u8,
|
||||
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
|
||||
#[serde(default = "image_pixel")]
|
||||
pub pixel_size: Vec2,
|
||||
#[serde(default)]
|
||||
pub resolution_units: u8,
|
||||
}
|
||||
fn image_display() -> u16 {
|
||||
7
|
||||
}
|
||||
fn image_fifty() -> u8 {
|
||||
50
|
||||
}
|
||||
fn image_pixel() -> Vec2 {
|
||||
Vec2::new(1.0, 1.0)
|
||||
}
|
||||
impl Default for Image {
|
||||
fn default() -> Self {
|
||||
Image {
|
||||
insert: Vec3::ZERO,
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(1.0, 1.0),
|
||||
path: String::new(),
|
||||
name: String::new(),
|
||||
clip: Vec::new(),
|
||||
clipping: false,
|
||||
display: image_display(),
|
||||
brightness: image_fifty(),
|
||||
contrast: image_fifty(),
|
||||
fade: 0,
|
||||
pixel_size: image_pixel(),
|
||||
resolution_units: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
@@ -718,6 +855,7 @@ impl EntityKind {
|
||||
.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()))))))
|
||||
.chain(m.block.iter().map(|b| Prim::Point(b.insert.xy())))
|
||||
.collect(),
|
||||
EntityKind::Image(i) => vec![Prim::Point(i.insert.xy())],
|
||||
EntityKind::Table(t) => vec![Prim::Point(t.insert.xy())],
|
||||
@@ -791,11 +929,7 @@ impl EntityKind {
|
||||
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::Spline(sp) => sp.transform(m),
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => {
|
||||
t3(&mut r.base);
|
||||
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
|
||||
@@ -843,6 +977,14 @@ impl EntityKind {
|
||||
t3(&mut t.insert);
|
||||
t.height *= s;
|
||||
}
|
||||
if let Some(b) = &mut ml.block {
|
||||
// Transformed exactly like a block reference.
|
||||
let mut k = EntityKind::Insert(b.clone());
|
||||
k.transform(m);
|
||||
if let EntityKind::Insert(nb) = k {
|
||||
*b = nb;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => {
|
||||
for l in &mut h.loops {
|
||||
@@ -919,10 +1061,10 @@ impl EntityKind {
|
||||
}
|
||||
}
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => vec![r.base.xy(), r.base.xy() + r.dir.xy()],
|
||||
EntityKind::Text(t) => vec![t.insert.xy()],
|
||||
EntityKind::Text(t) => vec![t.justify_point()],
|
||||
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::AttDef(a) => vec![a.text.justify_point()],
|
||||
EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.justify_point())).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(),
|
||||
|
||||
+65
-24
@@ -1,5 +1,7 @@
|
||||
//! Approximate entity bounds (used for extents, zoom and selection pre-filtering).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::{Arc, Bounds2, EPS, Polyline, Vec2, bulge_to_arc};
|
||||
|
||||
use crate::{Drawing, Entity, EntityKind, LwPolyline, Prim};
|
||||
@@ -12,10 +14,8 @@ pub const MAX_BLOCK_DEPTH: usize = 16;
|
||||
fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
use crate::{HAlign, VAlign};
|
||||
let w = t.height * 0.9 * t.width_factor.abs().max(0.01) * len.max(1) as f64;
|
||||
let origin = match t.halign {
|
||||
HAlign::Left | HAlign::Aligned | HAlign::Fit => t.insert,
|
||||
_ => t.align_pt.unwrap_or(t.insert),
|
||||
};
|
||||
// Justified text (TL, ML and BL too) hangs from its alignment point (DXF group 11).
|
||||
let origin = t.justify_point();
|
||||
let dx = match t.halign {
|
||||
HAlign::Left | HAlign::Aligned | HAlign::Fit => 0.0,
|
||||
HAlign::Center | HAlign::Middle => -w / 2.0,
|
||||
@@ -29,11 +29,37 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
(_, VAlign::Top) => -t.height,
|
||||
};
|
||||
let local = [Vec2::new(dx, dy), Vec2::new(dx + w, dy), Vec2::new(dx + w, dy + t.height), Vec2::new(dx, dy + t.height)];
|
||||
Bounds2::from_points(local.map(|p| origin.xy() + p.rotate(t.rotation)))
|
||||
Bounds2::from_points(local.map(|p| origin + p.rotate(t.rotation)))
|
||||
}
|
||||
|
||||
/// Whether an entity inside a block definition shows: its own invisible flag and its layer count,
|
||||
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
|
||||
/// entities a visibility state hides are invisible this way, and they sit far from the block's
|
||||
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
|
||||
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
|
||||
// Attribute definitions are not drawn in an insert (its attribs are, see below).
|
||||
!matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& e.common.visible
|
||||
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
|
||||
}
|
||||
|
||||
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
|
||||
fn blank(s: &str) -> bool {
|
||||
s.replace("\\~", " ").chars().all(char::is_whitespace)
|
||||
}
|
||||
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_in(d, e, depth, &mut HashMap::new())
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
/// by block name: every further reference to a block reuses them instead of expanding the block
|
||||
/// again, so nested or self-referencing blocks cost one pass over each definition instead of one
|
||||
/// per reference (which grows exponentially with the nesting depth).
|
||||
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
|
||||
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
|
||||
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
|
||||
EntityKind::MText(t) => {
|
||||
@@ -60,14 +86,23 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
if depth >= MAX_BLOCK_DEPTH {
|
||||
return Bounds2::EMPTY;
|
||||
}
|
||||
let Some(blk) = d.block(&ins.block) else { return Bounds2::from_points([ins.insert.xy()]) };
|
||||
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
|
||||
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
|
||||
// insertion point. Attributes below still count.
|
||||
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
|
||||
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))) };
|
||||
let inner = match blocks.get(&blk.name) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
blocks.insert(blk.name.clone(), inner);
|
||||
inner
|
||||
}
|
||||
};
|
||||
let mut b = if inner.is_empty() { Bounds2::EMPTY } 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);
|
||||
@@ -93,13 +128,20 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
b.add(dm.text_mid.xy());
|
||||
}
|
||||
// Room for text and arrows.
|
||||
let th = d.dim_style(&dm.style).map(|s| s.text_height * s.scale.max(1e-9)).unwrap_or(0.18) * d.header.f64("DIMSCALE", 1.0).max(1e-9);
|
||||
// The same scale the dimension's geometry uses.
|
||||
let th = d
|
||||
.dim_style(&dm.style)
|
||||
.map(|s| {
|
||||
let s = s.with_overrides(&dm.overrides);
|
||||
s.text_height * s.effective_scale(d.header.f64("DIMSCALE", 1.0))
|
||||
})
|
||||
.unwrap_or(0.18 * d.header.f64("DIMSCALE", 1.0).max(1e-9));
|
||||
b = b.expand(th * 2.5);
|
||||
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));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
b = b.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
}
|
||||
b
|
||||
@@ -109,6 +151,10 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
if let Some(t) = &m.text {
|
||||
b.add(t.insert.xy());
|
||||
}
|
||||
if let Some(ins) = &m.block {
|
||||
let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) };
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks));
|
||||
}
|
||||
b
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
@@ -132,14 +178,9 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
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
|
||||
}
|
||||
}
|
||||
// Infinite lines don't contribute to extents, rays included (as in ZOOM Extents;
|
||||
// real drawings carry stray rays millions of units away).
|
||||
Prim::Infinite { .. } => Bounds2::EMPTY,
|
||||
Prim::Fill(pts) => Bounds2::from_points(pts),
|
||||
};
|
||||
b = b.union(&pb);
|
||||
@@ -246,7 +287,7 @@ mod tests {
|
||||
#[test]
|
||||
fn right_justified_attdef_is_left_of_alignment_point() {
|
||||
let text = text(HAlign::Right, VAlign::Baseline);
|
||||
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new() };
|
||||
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let b = bounds(EntityKind::AttDef(a));
|
||||
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,9 @@ impl Header {
|
||||
h.set_f64("ELEVATION", 0.0);
|
||||
h.set_i64("LWDISPLAY", 0);
|
||||
h.set("INSBASE", HVal::Point(Vec3::ZERO));
|
||||
// Snap grid origin and rotation (SNAPANG in degrees).
|
||||
h.set("SNAPBASE", HVal::Point(Vec3::ZERO));
|
||||
h.set_f64("SNAPANG", 0.0);
|
||||
h
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ mod entity;
|
||||
mod extents;
|
||||
mod header;
|
||||
pub mod library;
|
||||
mod plotstyle;
|
||||
mod store;
|
||||
mod tables;
|
||||
|
||||
@@ -22,6 +23,7 @@ pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
|
||||
pub use header::{HVal, Header};
|
||||
pub use plotstyle::*;
|
||||
pub use store::EntityStore;
|
||||
pub use tables::*;
|
||||
|
||||
@@ -64,6 +66,9 @@ pub struct Drawing {
|
||||
pub ucss: Vec<Ucs>,
|
||||
pub layer_states: Vec<LayerState>,
|
||||
pub groups: Vec<Group>,
|
||||
/// Plot style tables kept with the drawing (STYLESMANAGER); they shadow built-in tables of
|
||||
/// the same name.
|
||||
pub plot_style_tables: Vec<PlotStyleTable>,
|
||||
/// Parametric constraints (GEOMCONSTRAINT / DIMCONSTRAINT).
|
||||
pub constraints: Vec<Constraint>,
|
||||
/// User parameters and parametric settings.
|
||||
@@ -113,6 +118,7 @@ impl Drawing {
|
||||
ucss: Vec::new(),
|
||||
layer_states: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
plot_style_tables: Vec::new(),
|
||||
constraints: Vec::new(),
|
||||
parametric: Parametric::default(),
|
||||
handseed: 0x100,
|
||||
@@ -147,6 +153,62 @@ impl Drawing {
|
||||
pub fn dim_style(&self, name: &str) -> Option<&DimStyle> {
|
||||
self.dim_styles.iter().find(|l| l.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The DIM* variables in the header that differ from the current dimension style, as
|
||||
/// [`DimStyle`] fields: AutoCAD's style overrides (set with SETVAR), which new dimensions
|
||||
/// take on.
|
||||
pub fn dim_overrides(&self) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
let Some(st) = self.dim_style(&self.header.str("DIMSTYLE", "Standard")) else { return out };
|
||||
let base = serde_json::to_value(st).unwrap_or_default();
|
||||
for (var, field) in DIMVARS {
|
||||
let Some(v) = self.header.get(var).and_then(|h| serde_json::to_value(h).ok()) else { continue };
|
||||
let mut cand = st.clone();
|
||||
if !cand.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v)])).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(cv) = serde_json::to_value(&cand).ok().and_then(|c| c.get(*field).cloned()) else { continue };
|
||||
let same = match (base.get(*field), &cv) {
|
||||
// Text style and arrow block names compare without case.
|
||||
(Some(serde_json::Value::String(a)), serde_json::Value::String(b))
|
||||
if matches!(*field, "textStyle" | "arrowBlock" | "arrowBlock1" | "arrowBlock2") =>
|
||||
{
|
||||
a.eq_ignore_ascii_case(b)
|
||||
}
|
||||
(b, cv) => b == Some(cv),
|
||||
};
|
||||
if !same {
|
||||
out.insert((*field).to_string(), cv);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Set the header's DIM* variables to the current dimension style's values, clearing the
|
||||
/// overrides (as making a style current or editing it does). Variables the header doesn't
|
||||
/// carry stay absent; ones that can't hold the style's value are removed.
|
||||
pub fn sync_dim_vars(&mut self) {
|
||||
let Some(st) = self.dim_style(&self.header.str("DIMSTYLE", "Standard")) else { return };
|
||||
let v = serde_json::to_value(st).unwrap_or_default();
|
||||
for (var, field) in DIMVARS {
|
||||
let Some(old) = self.header.get(var) else { continue };
|
||||
let new = match (old, v.get(*field)) {
|
||||
(HVal::Int(_), Some(serde_json::Value::Bool(b))) => Some(HVal::Int(i64::from(*b))),
|
||||
(HVal::Int(_), Some(serde_json::Value::Number(n))) => {
|
||||
n.as_i64().or_else(|| n.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64)).map(HVal::Int)
|
||||
}
|
||||
(HVal::Real(_), Some(serde_json::Value::Number(n))) => n.as_f64().map(HVal::Real),
|
||||
(HVal::Str(_), Some(serde_json::Value::String(s))) => Some(HVal::Str(s.clone())),
|
||||
_ => None,
|
||||
};
|
||||
match new {
|
||||
Some(h) => self.header.set(var, h),
|
||||
None => {
|
||||
self.header.vars.remove(*var);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn block(&self, name: &str) -> Option<&std::sync::Arc<Block>> {
|
||||
self.blocks.get(name).or_else(|| self.blocks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
//! The names follow the industry-common names users expect (DASHED, CENTER, ANSI31…); the
|
||||
//! dash and spacing values are our own.
|
||||
|
||||
use crate::{DashElement, Linetype};
|
||||
use crate::{DashElement, Drawing, Linetype};
|
||||
|
||||
/// The standard linetype library (inch-based lengths; metric drawings scale with LTSCALE).
|
||||
/// The standard linetype library, defined in inches. Load it into a drawing with
|
||||
/// [`standard_linetypes_for`], which scales it for metric drawings.
|
||||
pub fn standard_linetypes() -> Vec<Linetype> {
|
||||
let s = |name: &str, desc: &str, d: &[f64]| Linetype::simple(name, desc, d);
|
||||
vec![
|
||||
@@ -32,8 +33,15 @@ pub fn standard_linetypes() -> Vec<Linetype> {
|
||||
description: "Fenceline circle ----0-----0----".into(),
|
||||
pattern: vec![
|
||||
DashElement::dash(0.25),
|
||||
// The text goes where its element ends: centred between the two gaps.
|
||||
DashElement {
|
||||
text: Some("o".into()),
|
||||
style: Some("Standard".into()),
|
||||
scale: 0.1,
|
||||
offset: cadcraft_geom::Vec2::new(-0.035, -0.035),
|
||||
..DashElement::dash(-0.1)
|
||||
},
|
||||
DashElement::dash(-0.1),
|
||||
DashElement { text: Some("o".into()), scale: 0.1, ..DashElement::dash(-0.1) },
|
||||
DashElement::dash(0.5),
|
||||
],
|
||||
},
|
||||
@@ -41,8 +49,33 @@ pub fn standard_linetypes() -> Vec<Linetype> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The standard linetypes as loaded into `d`. In a metric drawing (`MEASUREMENT` 1) every length
|
||||
/// (dashes, gaps, text and shape size, offsets) is multiplied by 25.4, so the patterns measure
|
||||
/// in millimetres what they measure in inches in an imperial drawing (DASHED: 12.7 on, 6.35
|
||||
/// off) and LTSCALE 1 suits both, as CAD programs load ISO-scaled definitions for metric
|
||||
/// drawings. Definitions already in a drawing are not changed.
|
||||
pub fn standard_linetypes_for(d: &Drawing) -> Vec<Linetype> {
|
||||
let lib = standard_linetypes();
|
||||
if d.header.i64("MEASUREMENT", 0) != 1 {
|
||||
return lib;
|
||||
}
|
||||
lib.into_iter().map(|lt| scaled(lt, 25.4)).collect()
|
||||
}
|
||||
|
||||
/// `lt` with every length multiplied by `k`.
|
||||
fn scaled(mut lt: Linetype, k: f64) -> Linetype {
|
||||
for e in &mut lt.pattern {
|
||||
e.length *= k;
|
||||
e.offset = e.offset * k;
|
||||
if e.text.is_some() || e.shape.is_some() {
|
||||
e.scale *= k;
|
||||
}
|
||||
}
|
||||
lt
|
||||
}
|
||||
|
||||
/// A hatch pattern line family: angle (degrees), origin, offset (shift along, spacing), dashes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PatternLine {
|
||||
pub angle: f64,
|
||||
pub origin: (f64, f64),
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Plot style tables: how objects print.
|
||||
//!
|
||||
//! A colour-dependent table (`.ctb`) has one plot style per index colour 1..=255: an object
|
||||
//! prints with the style of its colour. A named table (`.stb`) holds styles that layers and
|
||||
//! objects name directly. Each style can replace the object's colour, fade it (screening), turn
|
||||
//! it grey, and replace its linetype and lineweight.
|
||||
//!
|
||||
//! CADCraft's built-in tables are our own definitions, not copies of any vendor's files:
|
||||
//! [`DEFAULT_CTB`] keeps the object's properties, [`MONOCHROME_CTB`] prints everything black
|
||||
//! and [`GRAYSCALE_CTB`] prints every colour as the grey of its luminance; the named
|
||||
//! [`DEFAULT_STB`] offers `Normal`, `Black`, `Screened 50%` and `Thick`, and
|
||||
//! [`MONOCHROME_STB`] prints `Normal` black.
|
||||
//!
|
||||
//! Tables are saved as files in our own JSON format (see `docs/plot-styles.md`):
|
||||
//! [`PlotStyleTable::to_file_text`] and [`PlotStyleTable::from_file_text`].
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Built-in colour-dependent table that prints objects with their own properties.
|
||||
pub const DEFAULT_CTB: &str = "default.ctb";
|
||||
/// Built-in colour-dependent table that prints everything black.
|
||||
pub const MONOCHROME_CTB: &str = "monochrome.ctb";
|
||||
/// Built-in colour-dependent table that prints every colour as a grey of its luminance.
|
||||
pub const GRAYSCALE_CTB: &str = "grayscale.ctb";
|
||||
|
||||
/// Built-in named table: `Normal` (object properties), `Black`, `Screened 50%` and `Thick`.
|
||||
pub const DEFAULT_STB: &str = "default.stb";
|
||||
/// Built-in named table whose `Normal` style prints black.
|
||||
pub const MONOCHROME_STB: &str = "monochrome.stb";
|
||||
|
||||
/// The names of the built-in tables.
|
||||
pub const BUILTIN_PLOT_STYLE_TABLES: &[&str] = &[DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB, DEFAULT_STB, MONOCHROME_STB];
|
||||
|
||||
/// The style every named table has, which keeps the object's properties unless redefined.
|
||||
pub const NORMAL_STYLE: &str = "Normal";
|
||||
|
||||
/// Most styles a named table may hold.
|
||||
pub const MAX_NAMED_STYLES: usize = 1024;
|
||||
/// Largest table file we read, in bytes.
|
||||
pub const MAX_TABLE_FILE: usize = 4 << 20;
|
||||
/// Format marker and version of our table files.
|
||||
const FILE_MARKER: &str = "cadcraftPlotStyleTable";
|
||||
const FILE_VERSION: u64 = 1;
|
||||
|
||||
/// Number of styles in a colour-dependent table (one per index colour 1..=255).
|
||||
pub const CTB_STYLES: usize = 255;
|
||||
|
||||
/// Line end style of a plot style.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EndStyle {
|
||||
#[default]
|
||||
Object,
|
||||
Butt,
|
||||
Square,
|
||||
Round,
|
||||
Diamond,
|
||||
}
|
||||
|
||||
/// Line join style of a plot style.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JoinStyle {
|
||||
#[default]
|
||||
Object,
|
||||
Miter,
|
||||
Bevel,
|
||||
Round,
|
||||
Diamond,
|
||||
}
|
||||
|
||||
/// Fill style of a plot style (for filled areas).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FillStyle {
|
||||
#[default]
|
||||
Object,
|
||||
Solid,
|
||||
Checkerboard,
|
||||
Crosshatch,
|
||||
Diamonds,
|
||||
HorizontalBars,
|
||||
SlantLeft,
|
||||
SlantRight,
|
||||
SquareDots,
|
||||
VerticalBars,
|
||||
}
|
||||
|
||||
/// One plot style. Serialized fields left at "use the object's" are omitted.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PlotStyle {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// Output colour (`"#rrggbb"` in JSON); `None` uses the object's colour.
|
||||
#[serde(with = "rgb_hex", skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
/// Print the colour as the grey of its luminance.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub grayscale: bool,
|
||||
/// Ink intensity in percent, 0 (white) to 100 (full colour).
|
||||
#[serde(skip_serializing_if = "is_full")]
|
||||
pub screening: u8,
|
||||
/// Linetype name; `None` uses the object's linetype.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
/// Lineweight in hundredths of a millimetre (0..=211); `None` uses the object's lineweight.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<u16>,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub end: EndStyle,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub join: JoinStyle,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub fill: FillStyle,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
fn is_full(s: &u8) -> bool {
|
||||
*s >= 100
|
||||
}
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
/// `Option<Rgb>` as `"#rrggbb"` (also read from `[r, g, b]`).
|
||||
mod rgb_hex {
|
||||
use cadcraft_color::Rgb;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(c: &Option<Rgb>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match c {
|
||||
Some(c) => s.serialize_str(&c.hex()),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Any {
|
||||
Hex(String),
|
||||
Triple(u8, u8, u8),
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Rgb>, D::Error> {
|
||||
Ok(match Option::<Any>::deserialize(d)? {
|
||||
None => None,
|
||||
Some(Any::Triple(r, g, b)) => Some(Rgb(r, g, b)),
|
||||
Some(Any::Hex(h)) => {
|
||||
Some(Rgb::parse_hex(h.trim().trim_start_matches('#')).ok_or_else(|| serde::de::Error::custom("colour must be \"#rrggbb\""))?)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlotStyle {
|
||||
fn default() -> Self {
|
||||
PlotStyle::object("")
|
||||
}
|
||||
}
|
||||
|
||||
impl PlotStyle {
|
||||
/// A style that keeps every object property.
|
||||
pub fn object(name: &str) -> PlotStyle {
|
||||
PlotStyle {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
color: None,
|
||||
grayscale: false,
|
||||
screening: 100,
|
||||
linetype: None,
|
||||
lineweight: None,
|
||||
end: EndStyle::Object,
|
||||
join: JoinStyle::Object,
|
||||
fill: FillStyle::Object,
|
||||
}
|
||||
}
|
||||
/// Whether the style leaves the colour alone.
|
||||
pub fn keeps_color(&self) -> bool {
|
||||
self.color.is_none() && !self.grayscale && self.screening >= 100
|
||||
}
|
||||
/// The printed colour of an object drawn in `rgb`: the style's colour (else `rgb`), turned
|
||||
/// grey when asked, then screened toward white.
|
||||
pub fn apply_color(&self, rgb: Rgb) -> Rgb {
|
||||
let c = self.color.unwrap_or(rgb);
|
||||
let c = if self.grayscale {
|
||||
let g = (c.luma().clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Rgb(g, g, g)
|
||||
} else {
|
||||
c
|
||||
};
|
||||
let s = u16::from(self.screening.min(100));
|
||||
// Screening mixes the ink with the white paper: 100 % = full ink, 0 % = white.
|
||||
let screen = |v: u8| (255 - (255 - u16::from(v)) * s / 100) as u8;
|
||||
Rgb(screen(c.0), screen(c.1), screen(c.2))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a table maps colours or names to styles.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PlotStyleKind {
|
||||
/// Colour-dependent (`.ctb`): style `i - 1` applies to index colour `i`.
|
||||
#[default]
|
||||
ColorDependent,
|
||||
/// Named (`.stb`): styles are picked by name.
|
||||
Named,
|
||||
}
|
||||
|
||||
/// A plot style table.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PlotStyleTable {
|
||||
/// File name, such as `monochrome.ctb`.
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub kind: PlotStyleKind,
|
||||
pub styles: Vec<PlotStyle>,
|
||||
}
|
||||
|
||||
impl PlotStyleTable {
|
||||
/// A colour-dependent table whose style for index colour `i` is `f(i)` (else object
|
||||
/// properties), named `Color_i`.
|
||||
pub fn color_dependent(name: &str, description: &str, f: impl Fn(u8) -> Option<PlotStyle>) -> PlotStyleTable {
|
||||
let styles = (1..=255u8)
|
||||
.map(|i| {
|
||||
let label = format!("Color_{i}");
|
||||
let mut s = f(i).unwrap_or_else(|| PlotStyle::object(&label));
|
||||
s.name = label;
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
PlotStyleTable { name: name.into(), description: description.into(), kind: PlotStyleKind::ColorDependent, styles }
|
||||
}
|
||||
|
||||
/// A named table holding `Normal` (object properties) followed by `styles`.
|
||||
pub fn named(name: &str, description: &str, styles: Vec<PlotStyle>) -> PlotStyleTable {
|
||||
let mut all = vec![PlotStyle::object(NORMAL_STYLE)];
|
||||
all.extend(styles);
|
||||
PlotStyleTable { name: name.into(), description: description.into(), kind: PlotStyleKind::Named, styles: all }.sanitized()
|
||||
}
|
||||
|
||||
/// A built-in table by name (case-insensitive; a `.ctb` extension may be left out).
|
||||
pub fn builtin(name: &str) -> Option<PlotStyleTable> {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
let n = if n.ends_with(".ctb") || n.ends_with(".stb") { n } else { format!("{n}.ctb") };
|
||||
let black = Some(Rgb(0, 0, 0));
|
||||
match n.as_str() {
|
||||
DEFAULT_STB => Some(PlotStyleTable::named(
|
||||
DEFAULT_STB,
|
||||
"Normal keeps object properties; Black, Screened 50% and Thick",
|
||||
vec![
|
||||
PlotStyle { name: "Black".into(), color: black, ..PlotStyle::default() },
|
||||
PlotStyle { name: "Screened 50%".into(), screening: 50, ..PlotStyle::default() },
|
||||
PlotStyle { name: "Thick".into(), lineweight: Some(50), ..PlotStyle::default() },
|
||||
],
|
||||
)),
|
||||
MONOCHROME_STB => {
|
||||
let mut t = PlotStyleTable::named(
|
||||
MONOCHROME_STB,
|
||||
"Normal prints black; Screened 50% prints grey",
|
||||
vec![PlotStyle { name: "Screened 50%".into(), color: black, screening: 50, ..PlotStyle::default() }],
|
||||
);
|
||||
if let Some(n) = t.styles.first_mut() {
|
||||
n.color = black;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
DEFAULT_CTB => Some(PlotStyleTable::color_dependent(DEFAULT_CTB, "Object colours, linetypes and lineweights", |_| None)),
|
||||
MONOCHROME_CTB => Some(PlotStyleTable::color_dependent(MONOCHROME_CTB, "Every colour prints black", |_| {
|
||||
Some(PlotStyle { color: Some(Rgb(0, 0, 0)), ..PlotStyle::default() })
|
||||
})),
|
||||
GRAYSCALE_CTB => Some(PlotStyleTable::color_dependent(GRAYSCALE_CTB, "Every colour prints as the grey of its luminance", |_| {
|
||||
Some(PlotStyle { grayscale: true, ..PlotStyle::default() })
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this table is colour-dependent.
|
||||
pub fn is_color_dependent(&self) -> bool {
|
||||
self.kind == PlotStyleKind::ColorDependent
|
||||
}
|
||||
|
||||
/// The style for index colour `aci` (1..=255) of a colour-dependent table.
|
||||
pub fn by_color(&self, aci: u8) -> Option<&PlotStyle> {
|
||||
if !self.is_color_dependent() || aci == 0 {
|
||||
return None;
|
||||
}
|
||||
self.styles.get(usize::from(aci) - 1)
|
||||
}
|
||||
|
||||
/// The style called `name` (case-insensitive).
|
||||
pub fn by_name(&self, name: &str) -> Option<&PlotStyle> {
|
||||
self.styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The table with hostile or inconsistent values fixed: screening at most 100 %,
|
||||
/// lineweights at most 2.11 mm, names at most 255 characters; a colour-dependent table has
|
||||
/// exactly 255 styles named `Color_1`..`Color_255`; a named table has unique, non-empty
|
||||
/// style names (at most [`MAX_NAMED_STYLES`]) starting with `Normal`.
|
||||
pub fn sanitized(mut self) -> PlotStyleTable {
|
||||
let cut = |s: &str| s.chars().take(255).collect::<String>();
|
||||
self.name = cut(self.name.trim());
|
||||
self.description = cut(&self.description);
|
||||
for s in &mut self.styles {
|
||||
s.name = cut(s.name.trim());
|
||||
s.description = cut(&s.description);
|
||||
s.screening = s.screening.min(100);
|
||||
s.lineweight = s.lineweight.map(|w| w.min(211));
|
||||
s.linetype = s.linetype.take().map(|l| cut(l.trim())).filter(|l| !l.is_empty());
|
||||
}
|
||||
match self.kind {
|
||||
PlotStyleKind::ColorDependent => {
|
||||
self.styles.truncate(CTB_STYLES);
|
||||
while self.styles.len() < CTB_STYLES {
|
||||
self.styles.push(PlotStyle::object(""));
|
||||
}
|
||||
for (i, s) in self.styles.iter_mut().enumerate() {
|
||||
s.name = format!("Color_{}", i + 1);
|
||||
}
|
||||
}
|
||||
PlotStyleKind::Named => {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.styles.retain(|s| !s.name.is_empty() && seen.insert(s.name.to_lowercase()));
|
||||
self.styles.truncate(MAX_NAMED_STYLES);
|
||||
match self.styles.iter().position(|s| s.name.eq_ignore_ascii_case(NORMAL_STYLE)) {
|
||||
Some(0) => {}
|
||||
Some(i) => {
|
||||
let n = self.styles.remove(i);
|
||||
self.styles.insert(0, n);
|
||||
}
|
||||
None => {
|
||||
self.styles.insert(0, PlotStyle::object(NORMAL_STYLE));
|
||||
self.styles.truncate(MAX_NAMED_STYLES);
|
||||
}
|
||||
}
|
||||
if let Some(n) = self.styles.first_mut() {
|
||||
n.name = NORMAL_STYLE.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The table as a file in our own format: pretty-printed JSON with a format marker.
|
||||
pub fn to_file_text(&self) -> String {
|
||||
let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert(FILE_MARKER.into(), FILE_VERSION.into());
|
||||
}
|
||||
serde_json::to_string_pretty(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read a table file written by [`PlotStyleTable::to_file_text`] (sanitized).
|
||||
pub fn from_file_text(text: &str) -> std::result::Result<PlotStyleTable, String> {
|
||||
if text.len() > MAX_TABLE_FILE {
|
||||
return Err("the plot style table file is too large".into());
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("not a CADCraft plot style table: {e}"))?;
|
||||
match v.get(FILE_MARKER).and_then(serde_json::Value::as_u64) {
|
||||
Some(FILE_VERSION) => {}
|
||||
Some(n) => return Err(format!("plot style table format version {n} is newer than this CADCraft reads")),
|
||||
None => return Err(format!("not a CADCraft plot style table (no `{FILE_MARKER}`)")),
|
||||
}
|
||||
let t: PlotStyleTable = serde_json::from_value(v).map_err(|e| format!("bad plot style table: {e}"))?;
|
||||
Ok(t.sanitized())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a page setup's table name means "no plot style table".
|
||||
pub fn no_plot_style_table(name: &str) -> bool {
|
||||
let n = name.trim();
|
||||
n.is_empty() || n.eq_ignore_ascii_case("none")
|
||||
}
|
||||
|
||||
/// The plot style table a page setup names, if it is one we know: the drawing's own tables
|
||||
/// first, then the built-in ones. `None` for no table (empty or "None") and for tables we don't
|
||||
/// have.
|
||||
pub fn plot_style_table(d: &crate::Drawing, name: &str) -> Option<PlotStyleTable> {
|
||||
if no_plot_style_table(name) {
|
||||
return None;
|
||||
}
|
||||
let n = name.trim();
|
||||
d.plot_style_tables.iter().find(|t| t.name.eq_ignore_ascii_case(n)).cloned().or_else(|| PlotStyleTable::builtin(n))
|
||||
}
|
||||
|
||||
/// The names of every table a drawing can use: its own, then the built-in ones it doesn't
|
||||
/// shadow.
|
||||
pub fn plot_style_table_names(d: &crate::Drawing) -> Vec<String> {
|
||||
let mut v: Vec<String> = d.plot_style_tables.iter().map(|t| t.name.clone()).collect();
|
||||
for b in BUILTIN_PLOT_STYLE_TABLES {
|
||||
if !v.iter().any(|n| n.eq_ignore_ascii_case(b)) {
|
||||
v.push((*b).to_string());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtins_are_complete_color_dependent_tables() {
|
||||
for n in [DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB] {
|
||||
let t = PlotStyleTable::builtin(n).unwrap();
|
||||
assert_eq!(t.name, n);
|
||||
assert!(t.is_color_dependent());
|
||||
assert_eq!(t.styles.len(), CTB_STYLES);
|
||||
assert_eq!(t.by_color(1).unwrap().name, "Color_1");
|
||||
assert_eq!(t.by_color(255).unwrap().name, "Color_255");
|
||||
assert!(t.by_color(0).is_none());
|
||||
}
|
||||
assert!(PlotStyleTable::builtin("Monochrome").is_some());
|
||||
assert!(PlotStyleTable::builtin("nope.ctb").is_none());
|
||||
assert!(no_plot_style_table("") && no_plot_style_table(" None ") && !no_plot_style_table("monochrome.ctb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_transforms() {
|
||||
let mono = PlotStyleTable::builtin(MONOCHROME_CTB).unwrap();
|
||||
assert_eq!(mono.by_color(1).unwrap().apply_color(Rgb(255, 0, 0)), Rgb(0, 0, 0));
|
||||
let gray = PlotStyleTable::builtin(GRAYSCALE_CTB).unwrap();
|
||||
let g = gray.by_color(3).unwrap().apply_color(Rgb(0, 255, 0));
|
||||
assert!(g.0 == g.1 && g.1 == g.2 && g.0 > 128, "{g:?}");
|
||||
let half = PlotStyle { color: Some(Rgb(0, 0, 0)), screening: 50, ..PlotStyle::default() };
|
||||
assert_eq!(half.apply_color(Rgb(9, 9, 9)), Rgb(128, 128, 128));
|
||||
let none = PlotStyle { screening: 0, ..PlotStyle::default() };
|
||||
assert_eq!(none.apply_color(Rgb(10, 20, 30)), Rgb(255, 255, 255));
|
||||
assert!(PlotStyle::default().keeps_color() && !half.keeps_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_builtins_and_sanitizing() {
|
||||
let t = PlotStyleTable::builtin("default.stb").unwrap();
|
||||
assert!(!t.is_color_dependent());
|
||||
assert_eq!(t.styles.first().unwrap().name, NORMAL_STYLE);
|
||||
assert!(t.by_name("black").is_some() && t.by_color(1).is_none());
|
||||
assert_eq!(PlotStyleTable::builtin(MONOCHROME_STB).unwrap().by_name("Normal").unwrap().color, Some(Rgb(0, 0, 0)));
|
||||
// Hostile values are clamped; names fixed.
|
||||
let bad = PlotStyleTable {
|
||||
name: " x.stb ".into(),
|
||||
kind: PlotStyleKind::Named,
|
||||
styles: vec![
|
||||
PlotStyle { name: "A".into(), screening: 250, lineweight: Some(9999), ..PlotStyle::default() },
|
||||
PlotStyle { name: "a".into(), ..PlotStyle::default() },
|
||||
PlotStyle { name: " ".into(), ..PlotStyle::default() },
|
||||
],
|
||||
..PlotStyleTable::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(bad.name, "x.stb");
|
||||
assert_eq!(bad.styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Normal", "A"]);
|
||||
assert_eq!((bad.styles[1].screening, bad.styles[1].lineweight), (100, Some(211)));
|
||||
let short = PlotStyleTable { styles: vec![PlotStyle::object("zz")], ..PlotStyleTable::default() }.sanitized();
|
||||
assert_eq!(short.styles.len(), CTB_STYLES);
|
||||
assert_eq!(short.styles[0].name, "Color_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_format_round_trip() {
|
||||
for name in BUILTIN_PLOT_STYLE_TABLES {
|
||||
let t = PlotStyleTable::builtin(name).unwrap();
|
||||
let text = t.to_file_text();
|
||||
assert!(text.contains("\"cadcraftPlotStyleTable\": 1"));
|
||||
assert_eq!(PlotStyleTable::from_file_text(&text).unwrap(), t, "{name}");
|
||||
}
|
||||
let mono = PlotStyleTable::builtin(MONOCHROME_CTB).unwrap().to_file_text();
|
||||
assert!(mono.contains("\"color\": \"#000000\""), "colours as hex");
|
||||
assert!(!mono.contains("screening"), "defaults are left out");
|
||||
assert!(PlotStyleTable::from_file_text("{}").is_err());
|
||||
assert!(PlotStyleTable::from_file_text("{\"cadcraftPlotStyleTable\": 9}").is_err());
|
||||
assert!(PlotStyleTable::from_file_text("not json").is_err());
|
||||
let rgb: PlotStyle = serde_json::from_value(serde_json::json!({"name": "x", "color": [1, 2, 3]})).unwrap();
|
||||
assert_eq!(rgb.color, Some(Rgb(1, 2, 3)));
|
||||
assert!(serde_json::from_value::<PlotStyle>(serde_json::json!({"color": "#zz0000"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_tables_shadow_builtins() {
|
||||
let mut d = crate::Drawing::new_metric();
|
||||
assert!(plot_style_table(&d, "monochrome.ctb").unwrap().by_color(1).unwrap().color.is_some());
|
||||
d.plot_style_tables.push(PlotStyleTable::color_dependent("Monochrome.ctb", "", |_| None));
|
||||
assert!(plot_style_table(&d, "monochrome.ctb").unwrap().by_color(1).unwrap().color.is_none());
|
||||
let names = plot_style_table_names(&d);
|
||||
assert_eq!(names.iter().filter(|n| n.eq_ignore_ascii_case("monochrome.ctb")).count(), 1);
|
||||
assert!(names.contains(&"default.stb".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_with_defaults() {
|
||||
let t = PlotStyleTable::builtin(GRAYSCALE_CTB).unwrap();
|
||||
let back: PlotStyleTable = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
|
||||
assert_eq!(back, t);
|
||||
let s: PlotStyle = serde_json::from_value(serde_json::json!({"name": "Thick", "lineweight": 50})).unwrap();
|
||||
assert_eq!((s.screening, s.lineweight, s.color), (100, Some(50), None));
|
||||
}
|
||||
}
|
||||
@@ -70,15 +70,20 @@ pub struct DashElement {
|
||||
pub style: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scale: f64,
|
||||
/// Rotation of embedded text or a shape, in radians.
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default)]
|
||||
pub offset: Vec2,
|
||||
/// The rotation of embedded text or a shape is absolute (`A=`) instead of relative to the
|
||||
/// line direction (`R=`).
|
||||
#[serde(default)]
|
||||
pub absolute: bool,
|
||||
}
|
||||
|
||||
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 }
|
||||
DashElement { length, text: None, shape: None, style: None, scale: 1.0, rotation: 0.0, offset: Vec2::ZERO, absolute: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,11 +212,19 @@ pub struct DimStyle {
|
||||
pub tol_decimals: u8,
|
||||
/// DIMTFAC tolerance text height relative to the dimension text.
|
||||
pub tol_scale: f64,
|
||||
/// DIMTZIN zero suppression of tolerance values (DIMZIN bits).
|
||||
pub tol_zero_suppression: u8,
|
||||
/// DIMALT alternate units, DIMALTF factor, DIMALTD decimals, DIMAPOST template.
|
||||
pub alt: bool,
|
||||
pub alt_factor: f64,
|
||||
pub alt_decimals: u8,
|
||||
pub alt_post: String,
|
||||
/// DIMALTRND rounding increment of alternate units (0 = none); DIMRND doesn't apply to them.
|
||||
pub alt_round: f64,
|
||||
/// DIMALTU: 1 sci, 2 dec, 3 eng, 4 arch stacked, 5 frac stacked, 6 arch, 7 frac, 8 windows.
|
||||
pub alt_unit: u8,
|
||||
/// DIMALTZ zero suppression of alternate units (DIMZIN bits).
|
||||
pub alt_zero_suppression: u8,
|
||||
/// DIMAUNIT: 0 decimal degrees, 1 deg/min/sec, 2 grads, 3 radians.
|
||||
pub angular_unit: u8,
|
||||
/// DIMSE1 / DIMSE2 suppress extension lines.
|
||||
@@ -257,10 +270,14 @@ pub const DIMVARS: &[(&str, &str)] = &[
|
||||
("DIMLIM", "limits"),
|
||||
("DIMTDEC", "tolDecimals"),
|
||||
("DIMTFAC", "tolScale"),
|
||||
("DIMTZIN", "tolZeroSuppression"),
|
||||
("DIMALT", "alt"),
|
||||
("DIMALTF", "altFactor"),
|
||||
("DIMALTD", "altDecimals"),
|
||||
("DIMAPOST", "altPost"),
|
||||
("DIMALTRND", "altRound"),
|
||||
("DIMALTU", "altUnit"),
|
||||
("DIMALTZ", "altZeroSuppression"),
|
||||
("DIMAUNIT", "angularUnit"),
|
||||
("DIMSE1", "suppressExt1"),
|
||||
("DIMSE2", "suppressExt2"),
|
||||
@@ -307,10 +324,14 @@ impl Default for DimStyle {
|
||||
limits: false,
|
||||
tol_decimals: 4,
|
||||
tol_scale: 1.0,
|
||||
tol_zero_suppression: 0,
|
||||
alt: false,
|
||||
alt_factor: 25.4,
|
||||
alt_decimals: 2,
|
||||
alt_post: String::new(),
|
||||
alt_round: 0.0,
|
||||
alt_unit: 2,
|
||||
alt_zero_suppression: 0,
|
||||
angular_unit: 0,
|
||||
suppress_ext1: false,
|
||||
suppress_ext2: false,
|
||||
@@ -368,6 +389,17 @@ impl DimStyle {
|
||||
}
|
||||
_ => coerced,
|
||||
};
|
||||
// Sizes that can't be negative (text height and tolerance scale: not zero either).
|
||||
let x = coerced.as_f64();
|
||||
let out_of_range = match field {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" => x.is_some_and(|x| x < 0.0),
|
||||
_ => false,
|
||||
};
|
||||
if out_of_range {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
}
|
||||
obj.insert(field.to_string(), coerced);
|
||||
match serde_json::from_value::<DimStyle>(cur.clone()) {
|
||||
Ok(ns) => *self = ns,
|
||||
@@ -382,6 +414,12 @@ impl DimStyle {
|
||||
rejected
|
||||
}
|
||||
|
||||
/// The overall scale: the style's DIMSCALE, else the drawing's `dimscale`, else 1. Zero means
|
||||
/// "scale to the layout viewport" in AutoCAD, which isn't computed here.
|
||||
pub fn effective_scale(&self, dimscale: f64) -> f64 {
|
||||
[self.scale, dimscale].into_iter().find(|k| *k > 0.0 && k.is_finite()).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// This style with a dimension's overrides applied.
|
||||
pub fn with_overrides(&self, o: &serde_json::Map<String, serde_json::Value>) -> DimStyle {
|
||||
let mut st = self.clone();
|
||||
@@ -500,6 +538,9 @@ pub struct PageSetup {
|
||||
pub plot_style_table: String,
|
||||
pub center: bool,
|
||||
pub lineweights: bool,
|
||||
/// Draw the layout on screen (and in its PNG/SVG exports) with its plot style table
|
||||
/// applied ("Display plot styles"; DXF plot layout flag 2).
|
||||
pub show_plot_styles: bool,
|
||||
}
|
||||
|
||||
impl Default for PageSetup {
|
||||
@@ -517,6 +558,7 @@ impl Default for PageSetup {
|
||||
plot_style_table: String::new(),
|
||||
center: false,
|
||||
lineweights: true,
|
||||
show_plot_styles: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,49 @@ fn hidden_layers_excluded_from_extents() {
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rays_and_xlines_do_not_stretch_extents() {
|
||||
// ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away.
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
let far = Vec3::new(-1_977_989.0, -1_952_638.0, 0.0);
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Ray(RayLine { base: far, dir: Vec3::new(-0.7, -0.7, 0.0) })).unwrap();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::XLine(RayLine { base: far, dir: Vec3::new(1.0, 0.0, 0.0) })).unwrap();
|
||||
let b = d.extents(&Space::Model);
|
||||
assert_eq!((b.min, b.max), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
|
||||
// An unloaded xref or a purged definition draws nothing, so its insertion point is not an extent.
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
let ins = |block: &str, x: f64, y: f64| {
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::new(x, y, 0.0),
|
||||
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,
|
||||
})
|
||||
};
|
||||
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
|
||||
d.add(&Space::Model, Common::default(), ins("Missing", -125_319.0, -79_990.0)).unwrap();
|
||||
d.add(&Space::Model, Common::default(), ins("Empty", 55_652.0, -3_448_576.0)).unwrap();
|
||||
let b = d.extents(&Space::Model);
|
||||
assert_eq!((b.min, b.max), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
// A block with geometry still counts.
|
||||
let mut full = Block::new("Full");
|
||||
full.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (2.0, 2.0))));
|
||||
d.blocks.insert("Full".into(), std::sync::Arc::new(full));
|
||||
d.add(&Space::Model, Common::default(), ins("Full", 10.0, 10.0)).unwrap();
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.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 });
|
||||
@@ -187,3 +230,237 @@ fn wide_arc_segment_extents_reach_the_outer_edge() {
|
||||
assert!(over > -1e-6 && over < 1e-3, "got {b:?}, exact {exact:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_insert(block: &str) -> EntityKind {
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.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,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity {
|
||||
Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind }
|
||||
}
|
||||
|
||||
/// Model space with one insert of a block holding a near line (0,0)-(1,1) plus `extra`.
|
||||
fn drawing_with_block(extra: Entity) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut blk = Block::new("B");
|
||||
blk.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (1.0, 1.0))));
|
||||
blk.entities.push(extra);
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
|
||||
d.add(&Space::Model, Common::default(), plain_insert("B")).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn far_line(layer: &str) -> Entity {
|
||||
on_layer(layer, line((5000.0, 5000.0), (6000.0, 6000.0)), 2)
|
||||
}
|
||||
|
||||
fn extents_pair(d: &Drawing) -> (Vec2, Vec2) {
|
||||
let b = d.extents(&Space::Model);
|
||||
(b.min, b.max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
|
||||
let mut far = far_line("0");
|
||||
far.common.visible = false;
|
||||
let d = drawing_with_block(far);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
// Control: the same entity visible does count.
|
||||
let d = drawing_with_block(far_line("0"));
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_on_off_or_frozen_layer_is_ignored() {
|
||||
for frozen in [false, true] {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
if frozen {
|
||||
layer.frozen = true;
|
||||
} else {
|
||||
layer.on = false;
|
||||
}
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}");
|
||||
// Turned back on, it counts again.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.on = true;
|
||||
l.frozen = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_on_layer_zero_counts_even_if_layer_zero_is_off_or_missing() {
|
||||
// Layer "0" takes the insert's layer, which the caller has already checked; so it counts here.
|
||||
// The insert itself sits on layer "Vis".
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common { layer: "Vis".into(), ..Default::default() }, plain_insert("B")).unwrap();
|
||||
let mut blk = Block::new("B");
|
||||
blk.entities.push(far_line("0"));
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
|
||||
// Layer 0 switched off.
|
||||
d.layer_mut("0").unwrap().on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
// Layer 0 frozen.
|
||||
let l = d.layer_mut("0").unwrap();
|
||||
l.on = true;
|
||||
l.frozen = true;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
// Layer 0 not defined at all.
|
||||
d.layers.retain(|l| l.name != "0");
|
||||
assert!(d.layer("0").is_none());
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_on_undefined_layer_counts() {
|
||||
// An unknown layer name is not hidden (is_none_or).
|
||||
let d = drawing_with_block(far_line("Nowhere"));
|
||||
assert!(d.layer("Nowhere").is_none());
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attdef_in_block_is_ignored_in_insert_extents() {
|
||||
let attdef = Entity::new(
|
||||
Handle(2),
|
||||
EntityKind::AttDef(Attrib {
|
||||
tag: "TAG".into(),
|
||||
text: Text {
|
||||
insert: Vec3::new(5000.0, 5000.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "x".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
}),
|
||||
);
|
||||
let d = drawing_with_block(attdef);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
}
|
||||
|
||||
fn text_kind(value: &str) -> EntityKind {
|
||||
EntityKind::Text(Text {
|
||||
insert: Vec3::new(10.0, 10.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 2.0,
|
||||
value: value.into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
})
|
||||
}
|
||||
|
||||
fn mtext_kind(contents: &str) -> EntityKind {
|
||||
EntityKind::MText(MText {
|
||||
insert: Vec3::new(10.0, 10.0, 0.0),
|
||||
height: 2.0,
|
||||
width: 0.0,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_text_and_mtext_have_no_bounds() {
|
||||
let d = Drawing::new_imperial();
|
||||
for s in ["", " ", " ", "\t \n", "\\~", " \\~ \\~", "\u{a0}"] {
|
||||
for kind in [text_kind(s), mtext_kind(s)] {
|
||||
let b = entity_bounds(&d, &Entity::new(Handle(1), kind), 0);
|
||||
assert!(b.is_empty(), "{s:?} should have empty bounds");
|
||||
}
|
||||
}
|
||||
// Blank texts do not stretch the drawing extents either.
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
d.add(&Space::Model, Common::default(), text_kind(" \\~")).unwrap();
|
||||
d.add(&Space::Model, Common::default(), mtext_kind("")).unwrap();
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_blank_text_and_mtext_have_bounds() {
|
||||
let d = Drawing::new_imperial();
|
||||
for kind in [text_kind("Hi"), text_kind(" a "), mtext_kind("Hi"), mtext_kind("a\\Pb"), mtext_kind("\\~x")] {
|
||||
let b = entity_bounds(&d, &Entity::new(Handle(1), kind.clone()), 0);
|
||||
assert!(!b.is_empty(), "{kind:?} should have bounds");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
// Dynamic blocks keep dimensional constraints on layer *ADSK_CONSTRAINTS (group 60 = 1).
|
||||
let dim = |d: &mut Drawing, far: Entity| {
|
||||
let mut blk = Block::new("*D1");
|
||||
blk.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (1.0, 0.0))));
|
||||
blk.entities.push(far);
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Dimension(Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(1.0, 1.0, 0.0),
|
||||
text_mid: Vec3::new(0.5, 1.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(1.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 1.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: vec![],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
// Invisible flag.
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut hidden = on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2);
|
||||
hidden.common.visible = false;
|
||||
dim(&mut d, hidden);
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
// Hidden by its layer state.
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut l = Layer::new("*ADSK_CONSTRAINTS");
|
||||
l.on = false;
|
||||
d.layers.push(l);
|
||||
dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2));
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
// Control: a visible far entity does count.
|
||||
let mut d = Drawing::new_imperial();
|
||||
dim(&mut d, far_line("0"));
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
@@ -10,4 +10,4 @@ workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.1"
|
||||
acadrust = "0.6.3"
|
||||
+49
-3
@@ -79,7 +79,43 @@ mod native {
|
||||
));
|
||||
}
|
||||
written.map_err(|e| format!("DWG→DXF: {e}"))?;
|
||||
Ok(out.buf)
|
||||
Ok(add_header_vars(out.buf, &doc.header))
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
/// the HEADER section: (name, group code, value).
|
||||
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
|
||||
[
|
||||
("$ANGBASE", 50, h.angle_base.to_string()),
|
||||
("$ANGDIR", 70, h.angle_direction.to_string()),
|
||||
("$FILLETRAD", 40, h.fillet_radius.to_string()),
|
||||
("$CHAMFERA", 40, h.chamfer_distance_a.to_string()),
|
||||
("$CHAMFERB", 40, h.chamfer_distance_b.to_string()),
|
||||
("$ELEVATION", 40, h.elevation.to_string()),
|
||||
("$THICKNESS", 40, h.thickness.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Add the [`unwritten_header_vars`] to the HEADER section of acadrust's DXF text, before its
|
||||
/// ENDSEC; a variable the writer already wrote is left alone. Text of another layout than
|
||||
/// acadrust's (` 0\r\nSECTION\r\n 2\r\nHEADER\r\n` … ` 0\r\nENDSEC\r\n`) is returned unchanged.
|
||||
fn add_header_vars(mut dxf: Vec<u8>, h: &acadrust::document::HeaderVariables) -> Vec<u8> {
|
||||
const START: &[u8] = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n";
|
||||
const END: &[u8] = b"\r\n 0\r\nENDSEC\r\n";
|
||||
if !dxf.starts_with(START) {
|
||||
return dxf;
|
||||
}
|
||||
let Some(end) = dxf.windows(END.len()).position(|w| w == END).map(|p| p + 2) else { return dxf };
|
||||
let section = dxf.get(..end).unwrap_or_default();
|
||||
let mut add = String::new();
|
||||
for (name, code, value) in unwritten_header_vars(h) {
|
||||
let written = section.windows(name.len() + 2).any(|w| w.starts_with(name.as_bytes()) && w.ends_with(b"\r\n"));
|
||||
if !written && value.parse::<f64>().is_ok_and(f64::is_finite) {
|
||||
add.push_str(&format!(" 9\r\n{name}\r\n{code:>3}\r\n{value}\r\n"));
|
||||
}
|
||||
}
|
||||
dxf.splice(end..end, add.into_bytes());
|
||||
dxf
|
||||
}
|
||||
|
||||
/// Collects the DXF text and fails the write once it would grow past `limit`.
|
||||
@@ -104,12 +140,22 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert DXF bytes into a DWG file.
|
||||
/// Oldest DWG version written; older DXF input is written as this version.
|
||||
const MIN_DWG_VERSION: acadrust::DxfVersion = acadrust::DxfVersion::AC1018;
|
||||
|
||||
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
|
||||
/// (AC1018).
|
||||
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let data = dxf.to_vec();
|
||||
let doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
|
||||
.map_err(|e| format!("DXF: {e}"))?;
|
||||
// Write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG has no true colours, transparency,
|
||||
// table styles or gradients, and readers can't tell its layouts apart reliably. The DXF
|
||||
// stays R2000; only the DWG is newer.
|
||||
if doc.version < MIN_DWG_VERSION {
|
||||
doc.version = MIN_DWG_VERSION;
|
||||
}
|
||||
acadrust::DwgWriter::write_to_vec(&doc).map_err(|e| format!("DWG write: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +96,8 @@ pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSn
|
||||
let store = d.space(space)?;
|
||||
let mut best: Option<(usize, Handle, AssocSnap)> = None;
|
||||
for e in store.iter() {
|
||||
for (rank, (snap, q)) in candidates(&e.kind).into_iter().enumerate() {
|
||||
let rank = match snap {
|
||||
AssocSnap::Mid => 100 + rank,
|
||||
AssocSnap::Center => 50 + rank,
|
||||
_ => rank.min(1),
|
||||
};
|
||||
for (i, (snap, q)) in candidates(&e.kind).into_iter().enumerate() {
|
||||
let rank = snap_rank(&snap, i);
|
||||
if close(p, q) && best.as_ref().is_none_or(|b| rank < b.0) {
|
||||
best = Some((rank, e.handle, snap));
|
||||
}
|
||||
@@ -110,11 +106,61 @@ pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSn
|
||||
best.map(|(_, h, s)| (h, s))
|
||||
}
|
||||
|
||||
/// Preference of the `i`-th candidate snap of an object (lower wins): endpoints and vertices,
|
||||
/// then centres, then midpoints.
|
||||
fn snap_rank(snap: &AssocSnap, i: usize) -> usize {
|
||||
match snap {
|
||||
AssocSnap::Mid => 100 + i,
|
||||
AssocSnap::Center => 50 + i,
|
||||
_ => i.min(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Associations for named definition points found at their positions.
|
||||
pub fn auto_assoc(d: &Drawing, space: &Space, pts: &[(&str, Vec2)]) -> Vec<DimAssoc> {
|
||||
pts.iter().filter_map(|(name, p)| find_snap(d, space, *p).map(|(handle, snap)| DimAssoc { point: (*name).into(), handle, snap })).collect()
|
||||
}
|
||||
|
||||
/// The snap candidates of one space, sorted by x, for many [`find_snap`] lookups in a row: a
|
||||
/// lookup is a binary search instead of a scan of the whole space. Dimensions add no candidates,
|
||||
/// so the index stays valid while a command adds dimensions (DIMCONTINUE/DIMBASELINE chains).
|
||||
pub struct SnapIndex {
|
||||
/// (point, rank, position in space order, object, snap).
|
||||
pts: Vec<(Vec2, usize, usize, Handle, AssocSnap)>,
|
||||
}
|
||||
|
||||
impl SnapIndex {
|
||||
pub fn new(d: &Drawing, space: &Space) -> Self {
|
||||
let mut pts = Vec::new();
|
||||
for e in d.space(space).into_iter().flat_map(|st| st.iter()) {
|
||||
for (i, (snap, q)) in candidates(&e.kind).into_iter().enumerate() {
|
||||
if q.is_finite() {
|
||||
pts.push((q, snap_rank(&snap, i), pts.len(), e.handle, snap));
|
||||
}
|
||||
}
|
||||
}
|
||||
pts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
SnapIndex { pts }
|
||||
}
|
||||
|
||||
/// The same snap as [`find_snap`] (lowest rank, then the first in space order).
|
||||
pub fn find(&self, p: Vec2) -> Option<(Handle, AssocSnap)> {
|
||||
if !p.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
|
||||
let lo = self.pts.partition_point(|c| c.0.x < p.x - tol);
|
||||
let hi = self.pts.partition_point(|c| c.0.x <= p.x + tol);
|
||||
let hit = self.pts.get(lo..hi)?.iter().filter(|c| close(p, c.0)).min_by_key(|c| (c.1, c.2))?;
|
||||
Some((hit.3, hit.4.clone()))
|
||||
}
|
||||
|
||||
/// [`auto_assoc`] through the index.
|
||||
pub fn auto_assoc(&self, pts: &[(&str, Vec2)]) -> Vec<DimAssoc> {
|
||||
pts.iter().filter_map(|(name, p)| self.find(*p).map(|(handle, snap)| DimAssoc { point: (*name).into(), handle, snap })).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A circle or arc (centre, radius) by handle.
|
||||
fn circle_of(d: &Drawing, h: Handle) -> Option<(Vec2, f64)> {
|
||||
match &d.entity(h)?.kind {
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
//! AUDIT: find and repair errors in a drawing's database.
|
||||
//!
|
||||
//! [`check`] reports what is wrong; [`repair`] fixes it. Repairs never guess at content: a missing
|
||||
//! layer or block definition is recreated with default properties (an empty block), a reference to
|
||||
//! a missing style or linetype is reassigned to `Standard` or `ByLayer`, references to objects that
|
||||
//! don't exist are dropped, objects whose geometry can't be drawn are removed, block definitions
|
||||
//! that contain themselves lose the reference that closes the loop, and objects sharing a handle
|
||||
//! get new ones.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Handle, Layer, Linetype, MLeaderStyle, TableStyle, TextStyle};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// One error found in the drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Issue {
|
||||
/// What is wrong, as a stable identifier (`missingLayer`, `invalidGeometry`, `blockCycle`…).
|
||||
pub kind: &'static str,
|
||||
/// Where: `header`, `tables`, `Model`, `layout <name>`, `block <name>`, `groups` or `constraints`.
|
||||
pub location: String,
|
||||
/// The object concerned, as a hex handle.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
pub message: String,
|
||||
/// What the repair does (or did).
|
||||
pub fix: String,
|
||||
}
|
||||
|
||||
/// The result of an audit.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Report {
|
||||
pub issues: Vec<Issue>,
|
||||
/// Whether the issues were fixed.
|
||||
pub fixed: bool,
|
||||
}
|
||||
|
||||
/// The most issue lines a report message lists; the rest are counted.
|
||||
const MAX_LINES: usize = 100;
|
||||
|
||||
impl Report {
|
||||
pub fn errors(&self) -> usize {
|
||||
self.issues.len()
|
||||
}
|
||||
|
||||
/// The command-line text: one line per issue (up to [`MAX_LINES`]), then the totals.
|
||||
pub fn message(&self) -> String {
|
||||
let mut lines: Vec<String> = self
|
||||
.issues
|
||||
.iter()
|
||||
.take(MAX_LINES)
|
||||
.map(|i| {
|
||||
let what = match &i.handle {
|
||||
Some(h) => format!("{}: object {h}: {}", i.location, i.message),
|
||||
None => format!("{}: {}", i.location, i.message),
|
||||
};
|
||||
if self.fixed { format!("{what} ({})", i.fix) } else { what }
|
||||
})
|
||||
.collect();
|
||||
if self.issues.len() > MAX_LINES {
|
||||
lines.push(format!("… and {} more", self.issues.len() - MAX_LINES));
|
||||
}
|
||||
let n = self.errors();
|
||||
let fixed = if self.fixed { n } else { 0 };
|
||||
lines.push(format!("Audit: {n} error{} found, {fixed} fixed.", if n == 1 { "" } else { "s" }));
|
||||
if n > 0 && !self.fixed {
|
||||
lines.push("Run AUDIT and answer Yes to fix them.".into());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// The programmatic result: `{errors, fixed, issues, message}`.
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"errors": self.errors(),
|
||||
"fixed": if self.fixed { self.errors() } else { 0 },
|
||||
"issues": self.issues,
|
||||
"message": self.message(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit `d` without changing it.
|
||||
pub fn check(d: &Drawing) -> Report {
|
||||
let mut copy = d.clone();
|
||||
Report { issues: repair(&mut copy), fixed: false }
|
||||
}
|
||||
|
||||
/// Audit `d` and fix what is wrong in place. A drawing without errors is left untouched.
|
||||
pub fn fix(d: &mut Drawing) -> Report {
|
||||
Report { issues: repair(d), fixed: true }
|
||||
}
|
||||
|
||||
/// Where a set of objects lives.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Loc {
|
||||
Model,
|
||||
Layout(usize),
|
||||
Block(String),
|
||||
}
|
||||
|
||||
impl Loc {
|
||||
fn label(&self, d: &Drawing) -> String {
|
||||
match self {
|
||||
Loc::Model => "Model".into(),
|
||||
Loc::Layout(i) => format!("layout {}", d.layouts.get(*i).map(|l| l.name.as_str()).unwrap_or("?")),
|
||||
Loc::Block(n) => format!("block {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn locations(d: &Drawing) -> Vec<Loc> {
|
||||
std::iter::once(Loc::Model).chain((0..d.layouts.len()).map(Loc::Layout)).chain(d.blocks.keys().cloned().map(Loc::Block)).collect()
|
||||
}
|
||||
|
||||
fn store<'a>(d: &'a Drawing, l: &Loc) -> Option<&'a EntityStore> {
|
||||
match l {
|
||||
Loc::Model => Some(&d.model),
|
||||
Loc::Layout(i) => d.layouts.get(*i).map(|l| &l.entities),
|
||||
Loc::Block(n) => d.blocks.get(n).map(|b| &b.entities),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_mut<'a>(d: &'a mut Drawing, l: &Loc) -> Option<&'a mut EntityStore> {
|
||||
match l {
|
||||
Loc::Model => Some(&mut d.model),
|
||||
Loc::Layout(i) => d.layouts.get_mut(*i).map(|l| &mut l.entities),
|
||||
Loc::Block(n) => d.blocks.get_mut(n).map(|b| &mut Arc::make_mut(b).entities),
|
||||
}
|
||||
}
|
||||
|
||||
/// A change to one object, applied after its store was read.
|
||||
enum Action {
|
||||
Remove(Handle),
|
||||
Replace(Box<Entity>),
|
||||
/// Give the object (by its old handle) a new handle.
|
||||
Rehandle(Handle),
|
||||
}
|
||||
|
||||
fn apply(d: &mut Drawing, loc: &Loc, actions: Vec<Action>) {
|
||||
if actions.is_empty() {
|
||||
return;
|
||||
}
|
||||
// New handles first: the store is borrowed below.
|
||||
let fresh: Vec<Handle> = actions.iter().filter(|a| matches!(a, Action::Rehandle(_))).map(|_| d.new_handle()).collect();
|
||||
let mut fresh = fresh.into_iter();
|
||||
let Some(st) = store_mut(d, loc) else { return };
|
||||
for a in actions {
|
||||
match a {
|
||||
Action::Remove(h) => {
|
||||
st.remove(h);
|
||||
}
|
||||
Action::Replace(e) => {
|
||||
st.replace(*e);
|
||||
}
|
||||
Action::Rehandle(h) => {
|
||||
if let (Some(e), Some(new)) = (st.remove(h), fresh.next()) {
|
||||
let mut e = (*e).clone();
|
||||
e.handle = new;
|
||||
st.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn issue(kind: &'static str, location: impl Into<String>, handle: Option<Handle>, message: String, fix: impl Into<String>) -> Issue {
|
||||
Issue { kind, location: location.into(), handle: handle.map(Handle::hex), message, fix: fix.into() }
|
||||
}
|
||||
|
||||
/// Find every error in `d` and fix it; returns what was found. Nothing is changed when nothing is
|
||||
/// wrong.
|
||||
pub fn repair(d: &mut Drawing) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
tables(d, &mut out);
|
||||
header(d, &mut out);
|
||||
handseed(d, &mut out);
|
||||
objects(d, &mut out);
|
||||
missing_blocks(d, &mut out);
|
||||
references(d, &mut out);
|
||||
block_cycles(d, &mut out);
|
||||
let all = all_handles(d);
|
||||
groups(d, &all, &mut out);
|
||||
constraints(d, &all, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn has_name<T>(items: &[T], name: &str, of: impl Fn(&T) -> &str) -> bool {
|
||||
items.iter().any(|t| of(t).eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The entries every drawing has, and references between table entries.
|
||||
fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let missing = |what: &str, name: &str, out: &mut Vec<Issue>| {
|
||||
out.push(issue("missingTableEntry", "tables", None, format!("the {what} \"{name}\" is missing"), "recreated with default properties"));
|
||||
};
|
||||
if d.layer("0").is_none() {
|
||||
missing("layer", "0", out);
|
||||
d.layers.insert(0, Layer::default());
|
||||
}
|
||||
for name in ["ByBlock", "ByLayer"] {
|
||||
if d.linetype(name).is_none() {
|
||||
missing("linetype", name, out);
|
||||
d.linetypes.push(Linetype { name: name.into(), description: String::new(), pattern: Vec::new() });
|
||||
}
|
||||
}
|
||||
if d.linetype("Continuous").is_none() {
|
||||
missing("linetype", "Continuous", out);
|
||||
d.linetypes.push(Linetype::continuous());
|
||||
}
|
||||
if d.text_style("Standard").is_none() {
|
||||
missing("text style", "Standard", out);
|
||||
d.text_styles.insert(0, TextStyle::default());
|
||||
}
|
||||
if d.dim_style("Standard").is_none() {
|
||||
missing("dimension style", "Standard", out);
|
||||
d.dim_styles.insert(0, DimStyle::default());
|
||||
}
|
||||
if !has_name(&d.mleader_styles, "Standard", |s| &s.name) {
|
||||
missing("multileader style", "Standard", out);
|
||||
d.mleader_styles.insert(0, MLeaderStyle::default());
|
||||
}
|
||||
if !has_name(&d.table_styles, "Standard", |s| &s.name) {
|
||||
missing("table style", "Standard", out);
|
||||
d.table_styles.insert(0, TableStyle::default());
|
||||
}
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
for l in &mut d.layers {
|
||||
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
|
||||
out.push(issue(
|
||||
"missingLinetype",
|
||||
"tables",
|
||||
None,
|
||||
format!("layer \"{}\" uses linetype \"{}\", which doesn't exist", l.name, l.linetype),
|
||||
"set to Continuous",
|
||||
));
|
||||
l.linetype = "Continuous".into();
|
||||
}
|
||||
}
|
||||
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
|
||||
for s in &mut d.dim_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
"missingTextStyle",
|
||||
"tables",
|
||||
None,
|
||||
format!("dimension style \"{}\" uses text style \"{}\", which doesn't exist", s.name, s.text_style),
|
||||
"set to Standard",
|
||||
));
|
||||
s.text_style = "Standard".into();
|
||||
}
|
||||
}
|
||||
for s in &mut d.mleader_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
"missingTextStyle",
|
||||
"tables",
|
||||
None,
|
||||
format!("multileader style \"{}\" uses text style \"{}\", which doesn't exist", s.name, s.text_style),
|
||||
"set to Standard",
|
||||
));
|
||||
s.text_style = "Standard".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current layer, linetype and style variables name entries that exist.
|
||||
fn header(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let vars: [(&str, &str, &str); 6] = [
|
||||
("CLAYER", "layer", "0"),
|
||||
("CELTYPE", "linetype", "ByLayer"),
|
||||
("TEXTSTYLE", "text style", "Standard"),
|
||||
("DIMSTYLE", "dimension style", "Standard"),
|
||||
("CMLEADERSTYLE", "multileader style", "Standard"),
|
||||
("CTABLESTYLE", "table style", "Standard"),
|
||||
];
|
||||
for (var, what, default) in vars {
|
||||
let Some(name) = d.header.get(var).map(|v| v.as_str().map(str::to_string)) else { continue };
|
||||
let exists = name.as_deref().is_some_and(|n| match var {
|
||||
"CLAYER" => d.layer(n).is_some(),
|
||||
"CELTYPE" => d.linetype(n).is_some(),
|
||||
"TEXTSTYLE" => d.text_style(n).is_some(),
|
||||
"DIMSTYLE" => d.dim_style(n).is_some(),
|
||||
"CMLEADERSTYLE" => has_name(&d.mleader_styles, n, |s| &s.name),
|
||||
_ => has_name(&d.table_styles, n, |s| &s.name),
|
||||
});
|
||||
if !exists {
|
||||
let shown = name.map(|n| format!("\"{n}\"")).unwrap_or_else(|| "a value that is not a name".into());
|
||||
out.push(issue(
|
||||
"missingCurrent",
|
||||
"header",
|
||||
None,
|
||||
format!("the current {what} ({var}) is {shown}, which doesn't exist"),
|
||||
format!("set to {default}"),
|
||||
));
|
||||
d.header.set_str(var, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn each_entity(d: &Drawing) -> impl Iterator<Item = &Arc<Entity>> {
|
||||
std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)).flat_map(|s| s.iter())
|
||||
}
|
||||
|
||||
fn all_handles(d: &Drawing) -> HashSet<Handle> {
|
||||
each_entity(d).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The next handle is above every handle in use, so new objects never take an existing one.
|
||||
fn handseed(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let Some(max) = each_entity(d).map(|e| e.handle).max() else { return };
|
||||
if max.0 >= d.handseed {
|
||||
out.push(issue(
|
||||
"handseed",
|
||||
"header",
|
||||
None,
|
||||
format!("the next handle ({:X}) is not above the largest one in use ({})", d.handseed, max.hex()),
|
||||
"moved past it",
|
||||
));
|
||||
d.bump_handseed(max);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `e`'s geometry is invalid, if it is.
|
||||
fn invalid_geometry(e: &Entity) -> Option<&'static str> {
|
||||
if !crate::finite::is_finite(e) {
|
||||
return Some("has infinite or undefined numbers");
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) if l.a == l.b => Some("has zero length"),
|
||||
EntityKind::Circle(c) if c.radius <= 0.0 => Some("has no radius"),
|
||||
EntityKind::Arc(a) if a.radius <= 0.0 => Some("has no radius"),
|
||||
EntityKind::Ellipse(el) if el.major.len() <= 0.0 || el.ratio <= 0.0 => Some("has no size"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects with invalid geometry are removed; handle 0 and handles used twice get new ones.
|
||||
fn objects(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let mut seen: HashSet<Handle> = HashSet::new();
|
||||
for loc in locations(d) {
|
||||
let Some(st) = store(d, &loc) else { continue };
|
||||
let label = loc.label(d);
|
||||
let mut actions = Vec::new();
|
||||
for e in st.iter() {
|
||||
if let Some(why) = invalid_geometry(e) {
|
||||
out.push(issue("invalidGeometry", &label, Some(e.handle), format!("{} {why}", e.kind.type_name()), "removed"));
|
||||
actions.push(Action::Remove(e.handle));
|
||||
} else if e.handle.0 == 0 || !seen.insert(e.handle) {
|
||||
let why = if e.handle.0 == 0 { "has no handle" } else { "has the same handle as another object" };
|
||||
out.push(issue("duplicateHandle", &label, Some(e.handle), format!("{} {why}", e.kind.type_name()), "given a new handle"));
|
||||
actions.push(Action::Rehandle(e.handle));
|
||||
}
|
||||
}
|
||||
apply(d, &loc, actions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Block references whose block definition is missing get an empty definition, keeping the
|
||||
/// references (and their attributes) until the block is redefined.
|
||||
fn missing_blocks(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let mut missing: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for e in each_entity(d) {
|
||||
if let EntityKind::Insert(i) = &e.kind
|
||||
&& d.block(&i.block).is_none()
|
||||
{
|
||||
*missing.entry(i.block.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
for (name, n) in missing {
|
||||
out.push(issue(
|
||||
"missingBlock",
|
||||
"tables",
|
||||
None,
|
||||
format!("{n} block reference{} to \"{name}\", which has no definition", if n == 1 { "" } else { "s" }),
|
||||
"created an empty block definition",
|
||||
));
|
||||
d.blocks.insert(name.clone(), Arc::new(Block::new(&name)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers objects are on that don't exist are created with default properties.
|
||||
fn missing_layers(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
// By folded name: a file may have very many layers.
|
||||
let mut known: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
for e in each_entity(d) {
|
||||
let n = &e.common.layer;
|
||||
if !n.trim().is_empty() && known.insert(n.to_ascii_lowercase()) {
|
||||
missing.push(n.clone());
|
||||
}
|
||||
}
|
||||
for n in missing {
|
||||
out.push(issue(
|
||||
"missingLayer",
|
||||
"tables",
|
||||
None,
|
||||
format!("layer \"{n}\" is used by objects but doesn't exist"),
|
||||
"created with default properties",
|
||||
));
|
||||
d.layers.push(Layer::new(&n));
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing layers, linetypes, styles, dimension blocks and associated objects referenced by objects.
|
||||
fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let all = all_handles(d);
|
||||
let ok_text = |d: &Drawing, n: &str| d.text_style(n).is_some();
|
||||
let ok_dim = |d: &Drawing, n: &str| d.dim_style(n).is_some();
|
||||
// Missing names, counted: (what, name) → (objects, first location).
|
||||
let mut counts: BTreeMap<(&'static str, String), (usize, String)> = BTreeMap::new();
|
||||
missing_layers(d, out);
|
||||
for loc in locations(d) {
|
||||
let Some(st) = store(d, &loc) else { continue };
|
||||
let label = loc.label(d);
|
||||
let mut actions = Vec::new();
|
||||
for e in st.iter() {
|
||||
let mut fixed = (**e).clone();
|
||||
let mut note = |what: &'static str, name: &str| {
|
||||
let c = counts.entry((what, name.to_string())).or_insert((0, label.clone()));
|
||||
c.0 += 1;
|
||||
};
|
||||
if fixed.common.layer.trim().is_empty() {
|
||||
note("layer", "");
|
||||
fixed.common.layer = "0".into();
|
||||
}
|
||||
let lt = &fixed.common.linetype;
|
||||
if !lt.eq_ignore_ascii_case("ByLayer") && !lt.eq_ignore_ascii_case("ByBlock") && d.linetype(lt).is_none() {
|
||||
note("linetype", lt);
|
||||
fixed.common.linetype = "ByLayer".into();
|
||||
}
|
||||
let mut text_style = |s: &mut String| {
|
||||
if !ok_text(d, s) {
|
||||
note("text style", s);
|
||||
*s = "Standard".into();
|
||||
}
|
||||
};
|
||||
match &mut fixed.kind {
|
||||
EntityKind::Text(t) => text_style(&mut t.style),
|
||||
EntityKind::MText(t) => text_style(&mut t.style),
|
||||
EntityKind::AttDef(a) => text_style(&mut a.text.style),
|
||||
EntityKind::Insert(i) => i.attribs.iter_mut().for_each(|a| text_style(&mut a.text.style)),
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
text_style(&mut t.style);
|
||||
}
|
||||
if !has_name(&d.mleader_styles, &m.style, |s| &s.name) {
|
||||
note("multileader style", &m.style);
|
||||
m.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
if !has_name(&d.table_styles, &t.style, |s| &s.name) {
|
||||
note("table style", &t.style);
|
||||
t.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Leader(l) => {
|
||||
if !ok_dim(d, &l.style) {
|
||||
note("dimension style", &l.style);
|
||||
l.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dim) => {
|
||||
if !ok_dim(d, &dim.style) {
|
||||
note("dimension style", &dim.style);
|
||||
dim.style = "Standard".into();
|
||||
}
|
||||
if let Some(b) = &dim.block
|
||||
&& d.block(b).is_none()
|
||||
{
|
||||
out.push(issue(
|
||||
"missingBlock",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
format!("dimension's block \"{b}\" doesn't exist"),
|
||||
"the dimension draws itself again",
|
||||
));
|
||||
dim.block = None;
|
||||
}
|
||||
let before = dim.assoc.len();
|
||||
dim.assoc.retain(|a| all.contains(&a.handle));
|
||||
if dim.assoc.len() < before {
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
"dimension is associated with an object that doesn't exist".into(),
|
||||
"association removed",
|
||||
));
|
||||
}
|
||||
}
|
||||
EntityKind::Viewport(vp) => {
|
||||
let before = vp.frozen_layers.len() + vp.layer_colors.len();
|
||||
vp.frozen_layers.retain(|n| d.layer(n).is_some());
|
||||
vp.layer_colors.retain(|(n, _)| d.layer(n).is_some());
|
||||
if vp.frozen_layers.len() + vp.layer_colors.len() < before {
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
"viewport lists layer settings for layers that don't exist".into(),
|
||||
"those entries removed",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if fixed != **e {
|
||||
actions.push(Action::Replace(Box::new(fixed)));
|
||||
}
|
||||
}
|
||||
apply(d, &loc, actions);
|
||||
}
|
||||
for ((what, name), (n, first)) in counts {
|
||||
let (fix, shown) = match what {
|
||||
"layer" => ("put on layer 0".to_string(), "no name".to_string()),
|
||||
"linetype" => ("set to ByLayer".into(), format!("\"{name}\"")),
|
||||
_ => ("set to Standard".into(), format!("\"{name}\"")),
|
||||
};
|
||||
out.push(issue(
|
||||
"missingReference",
|
||||
first,
|
||||
None,
|
||||
format!("{n} object{} {what} {shown}, which doesn't exist", if n == 1 { " uses" } else { "s use" }),
|
||||
fix,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Block definitions that contain themselves (directly or through other blocks) lose the
|
||||
/// reference that closes the loop.
|
||||
fn block_cycles(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
let names: Vec<String> = d.blocks.keys().cloned().collect();
|
||||
// Names resolve as `Drawing::block` does: the exact name, else one differing only in case.
|
||||
let exact: HashMap<&str, usize> = names.iter().enumerate().map(|(i, n)| (n.as_str(), i)).collect();
|
||||
let folded: HashMap<String, usize> = names.iter().enumerate().rev().map(|(i, n)| (n.to_ascii_lowercase(), i)).collect();
|
||||
let lookup = |n: &str| exact.get(n).or_else(|| folded.get(&n.to_ascii_lowercase())).copied();
|
||||
let edges: Vec<Vec<(Handle, usize)>> = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
d.blocks
|
||||
.get(n)
|
||||
.map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => lookup(&i.block).map(|t| (e.handle, t)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
// Iterative depth-first search: 0 unvisited, 1 on the current path, 2 done.
|
||||
let mut state = vec![0u8; names.len()];
|
||||
let mut cuts: Vec<(usize, Handle, usize)> = Vec::new();
|
||||
for root in 0..names.len() {
|
||||
if state.get(root) != Some(&0) {
|
||||
continue;
|
||||
}
|
||||
let mut stack: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
if let Some(s) = state.get_mut(root) {
|
||||
*s = 1;
|
||||
}
|
||||
while let Some((node, next)) = stack.last_mut() {
|
||||
let node = *node;
|
||||
match edges.get(node).and_then(|es| es.get(*next)) {
|
||||
Some(&(h, to)) => {
|
||||
*next += 1;
|
||||
match state.get(to) {
|
||||
Some(1) => cuts.push((node, h, to)),
|
||||
Some(0) => {
|
||||
if let Some(s) = state.get_mut(to) {
|
||||
*s = 1;
|
||||
}
|
||||
stack.push((to, 0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(s) = state.get_mut(node) {
|
||||
*s = 2;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (from, h, to) in cuts {
|
||||
let (Some(from), Some(to)) = (names.get(from), names.get(to)) else { continue };
|
||||
let msg = if from == to {
|
||||
"block contains a reference to itself".to_string()
|
||||
} else {
|
||||
format!("reference to block \"{to}\" makes the block contain itself")
|
||||
};
|
||||
out.push(issue("blockCycle", format!("block {from}"), Some(h), msg, "reference removed"));
|
||||
apply(d, &Loc::Block(from.clone()), vec![Action::Remove(h)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups list only objects that exist.
|
||||
fn groups(d: &mut Drawing, all: &HashSet<Handle>, out: &mut Vec<Issue>) {
|
||||
for g in &mut d.groups {
|
||||
let before = g.members.len();
|
||||
g.members.retain(|h| all.contains(h));
|
||||
let gone = before - g.members.len();
|
||||
if gone > 0 {
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
"groups",
|
||||
None,
|
||||
format!("group \"{}\" lists {gone} object{} exist", g.name, if gone == 1 { " that doesn't" } else { "s that don't" }),
|
||||
"removed from the group",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constraints refer only to objects that exist.
|
||||
fn constraints(d: &mut Drawing, all: &HashSet<Handle>, out: &mut Vec<Issue>) {
|
||||
let mut kept = Vec::with_capacity(d.constraints.len());
|
||||
for c in std::mem::take(&mut d.constraints) {
|
||||
match c.refs.iter().find(|r| !all.contains(&r.handle)) {
|
||||
Some(r) => out.push(issue(
|
||||
"danglingReference",
|
||||
"constraints",
|
||||
Some(r.handle),
|
||||
format!("constraint {} refers to an object that doesn't exist", c.id),
|
||||
"constraint removed",
|
||||
)),
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
d.constraints = kept;
|
||||
}
|
||||
+645
-100
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
||||
//! DIMTEDIT at the command line: "Select dimension", then a new text location or
|
||||
//! Left/Right/Center/Home/Angle. Each answer runs the JSON form on the picked dimension.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{dims_of, run_dimtedit, v3};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct TEditM {
|
||||
dim: Option<Handle>,
|
||||
asking_angle: bool,
|
||||
}
|
||||
|
||||
impl TEditM {
|
||||
fn run(&self, s: &mut Session, mut p: Value) -> Result<Step> {
|
||||
let Some(h) = self.dim else { return Ok(Step::Cancel) };
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("handles".into(), json!([h.hex()]));
|
||||
}
|
||||
run_dimtedit(s, &p)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for TEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMTEDIT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
// A single preselected dimension is taken at once.
|
||||
if s.settings.pickfirst
|
||||
&& let [h] = dims_of(s, &s.selection())[..]
|
||||
{
|
||||
self.dim = Some(h);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.dim.is_none() {
|
||||
Prompt::new("Select dimension", Accept::SELECT)
|
||||
} else if self.asking_angle {
|
||||
Prompt::new("Specify angle for dimension text", Accept::NUMBER)
|
||||
} else {
|
||||
Prompt::new("Specify new location for dimension text", Accept::POINT).kw(&["Left", "Right", "Center", "Home", "Angle"])
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.dim.is_none() {
|
||||
return Ok(match i {
|
||||
Input::Pick(hs) => {
|
||||
match dims_of(s, &hs).first() {
|
||||
Some(h) => self.dim = Some(*h),
|
||||
None if hs.is_empty() => {}
|
||||
None => s.echo("Object selected is not a dimension."),
|
||||
}
|
||||
Step::Continue
|
||||
}
|
||||
Input::Enter | Input::Cancel => Step::Cancel,
|
||||
_ => Step::Continue,
|
||||
});
|
||||
}
|
||||
if self.asking_angle {
|
||||
return match i {
|
||||
Input::Text(t) => match s.angle_settings().direction(&t) {
|
||||
Some(a) => self.run(s, json!({ "mode": "angle", "angle": a.to_degrees() })),
|
||||
None => {
|
||||
s.echo("Requires a valid angle.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Input::Enter | Input::Cancel => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => self.run(s, json!({ "at": [p.x, p.y] })),
|
||||
Input::Keyword(k) if k == "Angle" => {
|
||||
self.asking_angle = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => self.run(s, json!({ "mode": k.to_ascii_lowercase() })),
|
||||
Input::Enter | Input::Cancel => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
/// The text follows the cursor.
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.asking_angle {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(EntityKind::Dimension(mut d)) = self.dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) else { return Vec::new() };
|
||||
d.text_mid = v3(c);
|
||||
d.user_text_pos = true;
|
||||
d.block = None;
|
||||
vec![EntityKind::Dimension(d)]
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,47 @@ fn angular_between_lines_follows_rotation() {
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "45°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_angular_picks_polyline_segments() {
|
||||
// Rectangle sides are polyline segments, not lines (issue #10).
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang 0,0 10,5").unwrap();
|
||||
s.cmdline("dimangular").unwrap();
|
||||
s.cmdline("5,0").unwrap();
|
||||
s.cmdline("0,2").unwrap();
|
||||
s.cmdline("2,2").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let d = s.last_dim.unwrap();
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "90°");
|
||||
// A polyline arc segment works like an arc.
|
||||
s.cmdline("pline 20,0 30,0 a 30,10").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("dimangular").unwrap();
|
||||
let (cx, cy) = (30.0 + 5.0 * std::f64::consts::FRAC_1_SQRT_2, 5.0 - 5.0 * std::f64::consts::FRAC_1_SQRT_2);
|
||||
s.cmdline(&format!("{cx},{cy}")).unwrap();
|
||||
s.cmdline("40,5").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let d2 = s.last_dim.unwrap();
|
||||
assert_ne!(d, d2);
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d2)).value, "180°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_angular_picks_circle() {
|
||||
// Circle: the pick is the first endpoint, the center the vertex.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 5").unwrap();
|
||||
s.cmdline("dimangular").unwrap();
|
||||
s.cmdline("5,0").unwrap();
|
||||
assert!(s.current_prompt().unwrap().message.contains("Specify second angle endpoint"));
|
||||
s.cmdline("0,8").unwrap();
|
||||
s.cmdline("3,3").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let dm = dim(&s, s.last_dim.unwrap());
|
||||
assert!(near(dm.p15.xy(), Vec2::ZERO), "vertex at the center");
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_disassociate_and_reassociate() {
|
||||
let mut s = Session::new();
|
||||
@@ -383,3 +424,87 @@ fn mtext_rejects_nonpositive_height() {
|
||||
s.execute("mtext", &json!({ "at": [0, 0], "text": "hi", "height": 2 })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_dim_variables_apply_to_new_dimensions() {
|
||||
// Issue #66: a DIM* variable set with SETVAR is a style override that new dimensions take on.
|
||||
let mut s = Session::new();
|
||||
let linear = |s: &mut Session, y: f64| h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, y] })).unwrap());
|
||||
let text_height = |s: &Session, hd: Handle| {
|
||||
let d = s.doc().unwrap();
|
||||
let dm = dim(s, hd);
|
||||
let st = d.dim_style(&dm.style).cloned().unwrap();
|
||||
cadcraft_render::dimension_geometry(&dm, &st, d.header.f64("DIMSCALE", 1.0)).text_height
|
||||
};
|
||||
let first = linear(&mut s, -20.0);
|
||||
let base = text_height(&s, first);
|
||||
s.execute("setvar", &json!({ "name": "DIMSCALE", "value": 24 })).unwrap();
|
||||
let scaled = linear(&mut s, -40.0);
|
||||
assert!((text_height(&s, scaled) / base - 24.0).abs() < 1e-9);
|
||||
assert_eq!(dim(&s, scaled).overrides.get("scale"), Some(&json!(24.0)));
|
||||
assert!(dim(&s, first).overrides.is_empty(), "existing dimensions keep their size");
|
||||
assert!((text_height(&s, first) - base).abs() < 1e-12);
|
||||
// Extents use the same scale as the geometry: room for 2.5 text heights beyond the points.
|
||||
let d = s.doc().unwrap();
|
||||
let margin = |hd: Handle, span: f64| (cadcraft_doc::entity_bounds(d, d.entity(hd).unwrap(), 0).height() - span) / 2.0;
|
||||
assert!((margin(first, 20.0) - 2.5 * base).abs() < 1e-9);
|
||||
assert!((margin(scaled, 40.0) - 2.5 * text_height(&s, scaled)).abs() < 1e-9);
|
||||
|
||||
// Making a style current clears the overrides.
|
||||
s.execute("dimstyle.current", &json!({ "name": "Standard" })).unwrap();
|
||||
assert_eq!(crate::sysvars::get(&s, "DIMSCALE"), Some(json!(1.0)));
|
||||
let plain = linear(&mut s, -60.0);
|
||||
assert!(dim(&s, plain).overrides.is_empty());
|
||||
|
||||
// Editing the current style updates the variables, so the issue's workaround keeps working.
|
||||
s.execute("dimstyle", &json!({ "name": "Standard", "DIMSCALE": 24 })).unwrap();
|
||||
assert_eq!(crate::sysvars::get(&s, "DIMSCALE"), Some(json!(24.0)));
|
||||
let restyled = linear(&mut s, -80.0);
|
||||
assert!(dim(&s, restyled).overrides.is_empty());
|
||||
assert!((text_height(&s, restyled) / base - 24.0).abs() < 1e-9);
|
||||
|
||||
// A DIM* variable the header doesn't carry reads from the style and can be overridden too.
|
||||
let dec = crate::sysvars::get(&s, "DIMDEC").unwrap();
|
||||
assert_eq!(dec, json!(s.doc().unwrap().dim_style("Standard").unwrap().decimals));
|
||||
s.execute("setvar", &json!({ "name": "DIMDEC", "value": 1 })).unwrap();
|
||||
let one = linear(&mut s, -100.0);
|
||||
let one = dim(&s, one);
|
||||
assert_eq!(one.overrides.get("decimals"), Some(&json!(1)));
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &one).value, "100.0");
|
||||
|
||||
// DIMSCALE 0 (AutoCAD: scale to the layout viewport) never shrinks a dimension to nothing.
|
||||
s.execute("setvar", &json!({ "name": "DIMSCALE", "value": 0 })).unwrap();
|
||||
let zero = linear(&mut s, -120.0);
|
||||
assert!((text_height(&s, zero) - base).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linetype_rename_updates_references() {
|
||||
fn line(s: &mut Session, y: f64) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [[0.0, y], [10.0, y]] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("linetype", &json!({ "load": "DASHED" })).unwrap();
|
||||
s.execute("layer.new", &json!({ "name": "A", "linetype": "DASHED", "current": true })).unwrap();
|
||||
let by_layer = line(&mut s, 0.0);
|
||||
s.execute("linetype", &json!({ "current": "DASHED" })).unwrap();
|
||||
let explicit = line(&mut s, 2.0);
|
||||
let in_block = line(&mut s, 4.0);
|
||||
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [in_block.hex()], "keep": "delete" })).unwrap();
|
||||
s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": "MYDASH" })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.linetype("DASHED").is_none());
|
||||
assert!(d.linetype("MYDASH").is_some());
|
||||
assert_eq!(d.layers.iter().find(|l| l.name == "A").unwrap().linetype, "MYDASH");
|
||||
assert_eq!(d.layers.iter().find(|l| l.name == "0").unwrap().linetype, "Continuous");
|
||||
assert_eq!(d.entity(by_layer).unwrap().common.linetype, "ByLayer");
|
||||
assert_eq!(d.entity(explicit).unwrap().common.linetype, "MYDASH");
|
||||
let block: Vec<&str> = d.blocks["B"].entities.iter().map(|e| e.common.linetype.as_str()).collect();
|
||||
assert_eq!(block, ["MYDASH"]);
|
||||
assert_eq!(d.header.str("CELTYPE", ""), "MYDASH");
|
||||
// An existing target name and the reserved linetypes are refused.
|
||||
assert!(s.execute("rename", &json!({ "table": "linetype", "from": "MYDASH", "to": "Continuous" })).is_err());
|
||||
assert!(s.execute("rename", &json!({ "table": "linetype", "from": "ByLayer", "to": "X" })).is_err());
|
||||
assert_eq!(s.doc().unwrap().entity(by_layer).unwrap().common.linetype, "ByLayer");
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`).
|
||||
//!
|
||||
//! Arrays are created as separate copies of the source objects (non-associative). The interactive
|
||||
//! forms select objects, then edit the array in an option loop with a live preview, and create
|
||||
//! the copies on eXit (or Enter).
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_geom::{Bounds2, Mat3, TAU, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::line;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::modify::transform_entities;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most preview entities drawn while the array is edited.
|
||||
const PREVIEW_LIMIT: usize = 2000;
|
||||
/// Keyword-only prompts: each typed token is matched on its own (no free text).
|
||||
const KEYWORDS: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
|
||||
const NOT_ASSOCIATIVE: &str = "Associative arrays are not available yet; the array is created as separate objects.";
|
||||
const NO_LEVELS: &str = "Levels (3D arrays) are not available yet.";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("array", "Array", run_array)
|
||||
.alias(&["ar"])
|
||||
.params("{type?: \"rectangular\" (default) | \"polar\" | \"path\", ...the parameters of arrayrect, arraypolar or arraypath}")
|
||||
.interactive(|_| Ok(Box::new(ArrayM::default()))),
|
||||
CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect)
|
||||
.menu(&["Modify", "Array", "Rectangular Array"])
|
||||
.params("{handles?, rows, cols, rowSpacing, colSpacing}")
|
||||
.interactive(|_| Ok(Box::new(RectM::default()))),
|
||||
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(PolarM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------- geometry ----------------
|
||||
|
||||
/// Translations of the copies in a rectangular array (the source cell is skipped).
|
||||
fn rect_mats(rows: u64, cols: u64, rs: f64, cs: f64) -> impl Iterator<Item = Mat3> {
|
||||
(0..rows)
|
||||
.flat_map(move |r| (0..cols).map(move |c| (r, c)))
|
||||
.filter(|rc| *rc != (0, 0))
|
||||
.map(move |(r, c)| Mat3::translate(Vec2::new(cs * c as f64, rs * r as f64)))
|
||||
}
|
||||
|
||||
/// Transforms of the copies 1..n of a polar array filling `fill` degrees (positive = ccw).
|
||||
/// Without `rotate` each copy is moved so that `reference` travels around the circle.
|
||||
fn polar_mats(center: Vec2, n: u64, fill: f64, rotate: bool, reference: Vec2) -> impl Iterator<Item = Mat3> {
|
||||
let total = fill.to_radians();
|
||||
let full = (total.abs() - TAU).abs() < 1e-9;
|
||||
let step = if full { total / n.max(1) as f64 } else { total / n.saturating_sub(1).max(1) as f64 };
|
||||
(1..n).map(move |k| {
|
||||
let a = step * k as f64;
|
||||
if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(reference.rotate_about(center, a) - reference) }
|
||||
})
|
||||
}
|
||||
|
||||
fn bounds(d: &Drawing, hs: &[Handle]) -> Bounds2 {
|
||||
hs.iter().filter_map(|h| d.entity(*h).map(|e| cadcraft_doc::entity_bounds(d, e, 0))).fold(Bounds2::EMPTY, |a, b| a.union(&b))
|
||||
}
|
||||
|
||||
/// The centre of the first object (the point a non-rotated polar copy is moved by).
|
||||
fn first_center(d: &Drawing, hs: &[Handle]) -> Option<Vec2> {
|
||||
hs.first().and_then(|h| d.entity(*h)).map(|e| cadcraft_doc::entity_bounds(d, e, 0).center()).filter(|c| c.is_finite())
|
||||
}
|
||||
|
||||
/// MAXARRAY: refuse an array of `items` copies of `objs` objects that would exceed the limit
|
||||
/// (instead of creating fewer copies than asked for).
|
||||
pub(crate) fn check_size(s: &Session, items: u64, objs: usize) -> Result<()> {
|
||||
let max = s.settings.maxarray;
|
||||
let total = items.saturating_mul(objs.max(1) as u64);
|
||||
if total > max {
|
||||
return Err(other(format!(
|
||||
"The array would create {total} objects; the limit is {max} (MAXARRAY). Use fewer items or select fewer objects."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The most items a count prompt accepts when the rest of the array is `others` items of `objs` objects.
|
||||
fn max_count(s: &Session, others: u64, objs: usize) -> u64 {
|
||||
(s.settings.maxarray / others.max(1).saturating_mul(objs.max(1) as u64)).max(1)
|
||||
}
|
||||
|
||||
fn copy_all(s: &mut Session, hs: &[Handle], mats: impl Iterator<Item = Mat3>) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for m in mats {
|
||||
n += transform_entities(s, hs, &m, true)?.len();
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The objects transformed by each matrix, for the preview.
|
||||
fn preview_copies(s: &Session, hs: &[Handle], mats: impl Iterator<Item = Mat3>) -> Vec<EntityKind> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let src: Vec<EntityKind> = hs.iter().take(PREVIEW_LIMIT).filter_map(|h| d.entity(*h)).map(|e| e.kind.clone()).collect();
|
||||
if src.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for m in mats {
|
||||
for k in &src {
|
||||
if out.len() >= PREVIEW_LIMIT {
|
||||
return out;
|
||||
}
|
||||
let mut k = k.clone();
|
||||
k.transform(&m);
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
fn fmt(v: f64) -> String {
|
||||
format!("{v:.4}")
|
||||
}
|
||||
|
||||
/// A count typed at a prompt (Enter keeps `cur`); `None` for inputs the prompt ignores.
|
||||
fn count(i: &Input, cur: u64, max: u64) -> Result<Option<u64>> {
|
||||
match i {
|
||||
Input::Enter => Ok(Some(cur)),
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= max as f64)
|
||||
.map(|v| Some(v as u64))
|
||||
.ok_or_else(|| other(format!("Requires an integer between 1 and {max} (MAXARRAY limits the size of the array)."))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle in degrees typed at a prompt in AUNITS (Enter keeps `cur`); `None` for inputs the
|
||||
/// prompt ignores.
|
||||
fn angle(i: &Input, cur: f64, au: &crate::units::AngleSettings) -> Result<Option<f64>> {
|
||||
match i {
|
||||
Input::Enter => Ok(Some(cur)),
|
||||
Input::Text(t) => au.amount(t).map(|a| Some(a.to_degrees())).ok_or_else(|| other("Requires a valid angle in degrees.")),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(i: &Input, cur: bool) -> Option<bool> {
|
||||
match i {
|
||||
Input::Enter => Some(cur),
|
||||
Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().chars().next() {
|
||||
Some('y') => Some(true),
|
||||
Some('n') => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- JSON ----------------
|
||||
|
||||
fn run_array(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = match str_param(p, "type").unwrap_or("rectangular").to_ascii_lowercase().as_str() {
|
||||
"rectangular" | "rect" | "r" => "arrayrect",
|
||||
"polar" | "po" => "arraypolar",
|
||||
"path" | "pa" => "arraypath",
|
||||
_ => return Err(bad("array", "`type` must be \"rectangular\", \"polar\" or \"path\"")),
|
||||
};
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.run)(s, p)
|
||||
}
|
||||
|
||||
fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(3).max(1);
|
||||
let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(4).max(1);
|
||||
check_size(s, rows.saturating_mul(cols), hs.len())?;
|
||||
let rs = f64_or(p, "rowSpacing", 1.0);
|
||||
let cs = f64_or(p, "colSpacing", 1.0);
|
||||
let n = copy_all(s, &hs, rect_mats(rows, cols, rs, cs))?;
|
||||
Ok(json!({ "created": n }))
|
||||
}
|
||||
|
||||
fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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).max(1);
|
||||
check_size(s, n, hs.len())?;
|
||||
let fill = f64_or(p, "angle", 360.0);
|
||||
let rotate = bool_or(p, "rotate", true);
|
||||
let reference = first_center(s.doc()?, &hs).unwrap_or(c);
|
||||
let n = copy_all(s, &hs, polar_mats(c, n, fill, rotate, reference))?;
|
||||
Ok(json!({ "created": n }))
|
||||
}
|
||||
|
||||
// ---------------- ARRAY ----------------
|
||||
|
||||
/// ARRAY: select objects, choose the array type, then continue as ARRAYRECT, ARRAYPATH or
|
||||
/// ARRAYPOLAR with those objects.
|
||||
#[derive(Default)]
|
||||
struct ArrayM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
inner: Option<Box<dyn Interactive>>,
|
||||
}
|
||||
|
||||
impl Interactive for ArrayM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARRAY"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match &self.inner {
|
||||
Some(m) => m.prompt(s),
|
||||
None if !self.sel.done => self.sel.prompt(),
|
||||
None => Prompt::new("Enter array type", KEYWORDS).kw(&["Rectangular", "PAth", "POlar"]).default("Rectangular"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(m) = &mut self.inner {
|
||||
return m.input(s, i);
|
||||
}
|
||||
if !self.sel.done {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
let kind = match &i {
|
||||
Input::Enter => "Rectangular",
|
||||
Input::Keyword(k) => k.as_str(),
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
};
|
||||
let objs = self.objs.clone();
|
||||
let (m, step): (Box<dyn Interactive>, Step) = match kind {
|
||||
"PAth" => (Box::new(super::modify2::PathArrayM::with_objects(objs)), Step::Continue),
|
||||
"POlar" => {
|
||||
let mut m = PolarM::with_objects(objs);
|
||||
let step = m.ready(s);
|
||||
(Box::new(m), step)
|
||||
}
|
||||
_ => {
|
||||
let mut m = RectM::with_objects(objs);
|
||||
let step = m.ready(s)?;
|
||||
(Box::new(m), step)
|
||||
}
|
||||
};
|
||||
self.inner = Some(m);
|
||||
Ok(step)
|
||||
}
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ARRAYRECT ----------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum RStage {
|
||||
#[default]
|
||||
Main,
|
||||
Base,
|
||||
/// COUnt: columns, then rows.
|
||||
CountCols,
|
||||
CountRows,
|
||||
/// Spacing: between columns (or a unit cell), then between rows.
|
||||
SpaceCols,
|
||||
SpaceRows,
|
||||
UnitCell,
|
||||
/// COLumns: count, then spacing (or the total).
|
||||
ColsCount,
|
||||
ColsSpace,
|
||||
ColsTotal,
|
||||
/// Rows: count, then spacing (or the total), then the elevation increment.
|
||||
RowsCount,
|
||||
RowsSpace,
|
||||
RowsTotal,
|
||||
RowsElev,
|
||||
}
|
||||
|
||||
/// ARRAYRECT: select objects, then edit columns/rows/spacing in a loop; eXit creates the copies.
|
||||
#[derive(Default)]
|
||||
struct RectM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
stage: RStage,
|
||||
cols: u64,
|
||||
rows: u64,
|
||||
cs: f64,
|
||||
rs: f64,
|
||||
/// Array base point (distances picked with a point are measured from it).
|
||||
base: Vec2,
|
||||
centroid: Vec2,
|
||||
/// Unit cell: the first corner, once picked.
|
||||
corner: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl RectM {
|
||||
fn with_objects(objs: Vec<Handle>) -> Self {
|
||||
RectM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, ..RectM::default() }
|
||||
}
|
||||
|
||||
/// Objects are selected: set the defaults (4 columns × 3 rows, 1.5 × the selection size apart).
|
||||
fn ready(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let b = bounds(s.doc()?, &self.objs);
|
||||
let (w, h) = (b.width(), b.height());
|
||||
let size = |v: f64, o: f64| match (v.is_finite() && v > 1e-9, o.is_finite() && o > 1e-9) {
|
||||
(true, _) => v * 1.5,
|
||||
(false, true) => o * 1.5,
|
||||
_ => 1.0,
|
||||
};
|
||||
(self.cols, self.rows, self.cs, self.rs) = (4, 3, size(w, h), size(h, w));
|
||||
self.centroid = if b.center().is_finite() { b.center() } else { Vec2::ZERO };
|
||||
self.base = self.centroid;
|
||||
self.stage = RStage::Main;
|
||||
s.echo("Type = Rectangular Associative = No");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// A distance typed or picked (from the base point); Enter keeps `cur`.
|
||||
fn distance(&self, i: &Input, cur: f64) -> Result<Option<f64>> {
|
||||
let d = match i {
|
||||
Input::Enter => return Ok(Some(cur)),
|
||||
Input::Text(t) => number(t),
|
||||
Input::Point(p) => Some(self.base.dist(*p)),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
d.filter(|d| d.is_finite() && d.abs() > 1e-12).map(Some).ok_or_else(|| other("Requires a nonzero distance."))
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||||
check_size(s, self.rows.saturating_mul(self.cols), self.objs.len())?;
|
||||
copy_all(s, &self.objs, rect_mats(self.rows, self.cols, self.rs, self.cs))?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn main(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let k = match i {
|
||||
Input::Enter => return self.finish(s),
|
||||
Input::Keyword(k) => k,
|
||||
Input::Point(_) => return Err(other("Editing the array with grips is not available yet; choose an option.")),
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
};
|
||||
self.stage = match k.as_str() {
|
||||
"eXit" => return self.finish(s),
|
||||
"Base point" => RStage::Base,
|
||||
"COUnt" => RStage::CountCols,
|
||||
"Spacing" => RStage::SpaceCols,
|
||||
"COLumns" => RStage::ColsCount,
|
||||
"Rows" => RStage::RowsCount,
|
||||
"Levels" => {
|
||||
s.echo(NO_LEVELS);
|
||||
RStage::Main
|
||||
}
|
||||
_ => {
|
||||
s.echo(NOT_ASSOCIATIVE);
|
||||
RStage::Main
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RectM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARRAYRECT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
return self.ready(s);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
use RStage::*;
|
||||
if !self.sel.done {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
let cols = || Prompt::new("Enter the number of columns", Accept::NUMBER).default(self.cols.to_string());
|
||||
let rows = || Prompt::new("Enter the number of rows", Accept::NUMBER).default(self.rows.to_string());
|
||||
let dist = |m: &str, d: f64| Prompt::new(m, Accept::POINT_OR_NUMBER).default(fmt(d)).base(self.base);
|
||||
match self.stage {
|
||||
Main => Prompt::new("Select grip to edit array", Accept::POINT)
|
||||
.kw(&["ASsociative", "Base point", "COUnt", "Spacing", "COLumns", "Rows", "Levels", "eXit"])
|
||||
.default("eXit"),
|
||||
Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"),
|
||||
CountCols | ColsCount => cols(),
|
||||
CountRows | RowsCount => rows(),
|
||||
SpaceCols => dist("Specify the distance between columns", self.cs).kw(&["Unit cell"]),
|
||||
SpaceRows => dist("Specify the distance between rows", self.rs),
|
||||
UnitCell => match self.corner {
|
||||
None => Prompt::new("Specify first corner of the unit cell", Accept::POINT).base(self.base),
|
||||
Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(c),
|
||||
},
|
||||
ColsSpace => dist("Specify the distance between columns", self.cs).kw(&["Total"]),
|
||||
ColsTotal => dist("Specify the distance between the first and last column", self.cs * self.cols.saturating_sub(1) as f64),
|
||||
RowsSpace => dist("Specify the distance between rows", self.rs).kw(&["Total"]),
|
||||
RowsTotal => dist("Specify the distance between the first and last row", self.rs * self.rows.saturating_sub(1) as f64),
|
||||
RowsElev => Prompt::new("Specify the incrementing elevation between rows", Accept::NUMBER).default("0"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
use RStage::*;
|
||||
if !self.sel.done {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
self.ready(s)
|
||||
}
|
||||
};
|
||||
}
|
||||
let stage = self.stage;
|
||||
let next = match (stage, &i) {
|
||||
(Main, _) => return self.main(s, i),
|
||||
(Base, Input::Point(p)) => {
|
||||
self.base = *p;
|
||||
Main
|
||||
}
|
||||
(Base, Input::Enter) => {
|
||||
self.base = self.centroid;
|
||||
Main
|
||||
}
|
||||
(Base, Input::Keyword(_)) => {
|
||||
s.echo("Key points are not available yet; specify a point.");
|
||||
Base
|
||||
}
|
||||
(CountCols | ColsCount, _) => match count(&i, self.cols, max_count(s, self.rows, self.objs.len()))? {
|
||||
Some(n) => {
|
||||
self.cols = n;
|
||||
if stage == CountCols { CountRows } else { ColsSpace }
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(CountRows | RowsCount, _) => match count(&i, self.rows, max_count(s, self.cols, self.objs.len()))? {
|
||||
Some(n) => {
|
||||
self.rows = n;
|
||||
if stage == CountRows { Main } else { RowsSpace }
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(SpaceCols, Input::Keyword(_)) => {
|
||||
self.corner = None;
|
||||
UnitCell
|
||||
}
|
||||
(ColsSpace, Input::Keyword(_)) => ColsTotal,
|
||||
(RowsSpace, Input::Keyword(_)) => RowsTotal,
|
||||
(SpaceCols | ColsSpace, _) => match self.distance(&i, self.cs)? {
|
||||
Some(d) => {
|
||||
self.cs = d;
|
||||
if stage == SpaceCols { SpaceRows } else { Main }
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(SpaceRows | RowsSpace, _) => match self.distance(&i, self.rs)? {
|
||||
Some(d) => {
|
||||
self.rs = d;
|
||||
if stage == SpaceRows { Main } else { RowsElev }
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(ColsTotal, _) => match self.distance(&i, self.cs * self.cols.saturating_sub(1) as f64)? {
|
||||
Some(t) => {
|
||||
self.cs = t / self.cols.saturating_sub(1).max(1) as f64;
|
||||
Main
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(RowsTotal, _) => match self.distance(&i, self.rs * self.rows.saturating_sub(1) as f64)? {
|
||||
Some(t) => {
|
||||
self.rs = t / self.rows.saturating_sub(1).max(1) as f64;
|
||||
RowsElev
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(RowsElev, Input::Enter) => Main,
|
||||
(RowsElev, Input::Text(t)) => match number(t) {
|
||||
Some(z) if z.is_finite() => {
|
||||
if z.abs() > 1e-12 {
|
||||
s.echo("Row elevation increments (3D) are not available yet; the rows stay at the same elevation.");
|
||||
}
|
||||
Main
|
||||
}
|
||||
_ => return Err(other("Requires a number.")),
|
||||
},
|
||||
(UnitCell, Input::Point(p)) => match self.corner {
|
||||
None => {
|
||||
self.corner = Some(*p);
|
||||
UnitCell
|
||||
}
|
||||
Some(c) => {
|
||||
let (dx, dy) = (p.x - c.x, p.y - c.y);
|
||||
if dx.abs() <= 1e-12 || dy.abs() <= 1e-12 {
|
||||
return Err(other("The unit cell must have a width and a height."));
|
||||
}
|
||||
(self.cs, self.rs, self.corner) = (dx, dy, None);
|
||||
Main
|
||||
}
|
||||
},
|
||||
(UnitCell, Input::Enter) => {
|
||||
self.corner = None;
|
||||
Main
|
||||
}
|
||||
_ => stage,
|
||||
};
|
||||
self.stage = next;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
if !self.sel.done {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = preview_copies(s, &self.objs, rect_mats(self.rows, self.cols, self.rs, self.cs));
|
||||
match (self.stage, self.corner) {
|
||||
(RStage::UnitCell, Some(c)) => out.push(line(c, cursor)),
|
||||
(RStage::SpaceCols | RStage::SpaceRows | RStage::ColsSpace | RStage::ColsTotal | RStage::RowsSpace | RStage::RowsTotal, _) => {
|
||||
out.push(line(self.base, cursor))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ARRAYPOLAR ----------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum PStage {
|
||||
#[default]
|
||||
Center,
|
||||
/// Base point asked from the center prompt (returns there).
|
||||
CenterBase,
|
||||
Main,
|
||||
Base,
|
||||
Items,
|
||||
Between,
|
||||
Fill,
|
||||
Rotate,
|
||||
}
|
||||
|
||||
/// ARRAYPOLAR: select objects, specify the center, then edit items/angles in a loop; eXit creates
|
||||
/// the copies.
|
||||
struct PolarM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
stage: PStage,
|
||||
center: Vec2,
|
||||
items: u64,
|
||||
/// Fill angle in degrees (positive = counterclockwise).
|
||||
fill: f64,
|
||||
rotate: bool,
|
||||
/// Base point: the point non-rotated copies are moved by (default: the first object's centre).
|
||||
base: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Default for PolarM {
|
||||
fn default() -> Self {
|
||||
PolarM {
|
||||
sel: SelectPhase::default(),
|
||||
objs: Vec::new(),
|
||||
stage: PStage::Center,
|
||||
center: Vec2::ZERO,
|
||||
items: 6,
|
||||
fill: 360.0,
|
||||
rotate: true,
|
||||
base: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PolarM {
|
||||
fn with_objects(objs: Vec<Handle>) -> Self {
|
||||
PolarM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, ..PolarM::default() }
|
||||
}
|
||||
|
||||
fn ready(&mut self, s: &mut Session) -> Step {
|
||||
self.stage = PStage::Center;
|
||||
s.echo("Type = Polar Associative = No");
|
||||
Step::Continue
|
||||
}
|
||||
|
||||
fn full(&self) -> bool {
|
||||
(self.fill.abs() - 360.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// The angle between items, derived from the item count and the fill angle.
|
||||
fn between(&self) -> f64 {
|
||||
match self.items {
|
||||
0 | 1 => self.fill,
|
||||
n if self.full() => self.fill / n as f64,
|
||||
n => self.fill / (n - 1) as f64,
|
||||
}
|
||||
}
|
||||
|
||||
fn mats(&self, s: &Session, center: Vec2) -> impl Iterator<Item = Mat3> {
|
||||
let reference = self.base.or_else(|| s.doc().ok().and_then(|d| first_center(d, &self.objs))).unwrap_or(center);
|
||||
polar_mats(center, self.items, self.fill, self.rotate, reference)
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||||
check_size(s, self.items, self.objs.len())?;
|
||||
let mats: Vec<Mat3> = self.mats(s, self.center).collect();
|
||||
copy_all(s, &self.objs, mats.into_iter())?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn main(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let k = match i {
|
||||
Input::Enter => return self.finish(s),
|
||||
Input::Keyword(k) => k,
|
||||
Input::Point(_) => return Err(other("Editing the array with grips is not available yet; choose an option.")),
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
};
|
||||
self.stage = match k.as_str() {
|
||||
"eXit" => return self.finish(s),
|
||||
"Base point" => PStage::Base,
|
||||
"Items" => PStage::Items,
|
||||
"Angle between" => PStage::Between,
|
||||
"Fill angle" => PStage::Fill,
|
||||
"ROTate items" => PStage::Rotate,
|
||||
"ROWs" => {
|
||||
s.echo("Rows in polar arrays are not available yet.");
|
||||
PStage::Main
|
||||
}
|
||||
"Levels" => {
|
||||
s.echo(NO_LEVELS);
|
||||
PStage::Main
|
||||
}
|
||||
_ => {
|
||||
s.echo(NOT_ASSOCIATIVE);
|
||||
PStage::Main
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PolarM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARRAYPOLAR"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
return Ok(self.ready(s));
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
use PStage::*;
|
||||
if !self.sel.done {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
match self.stage {
|
||||
Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base point", "Axis of rotation"]),
|
||||
CenterBase | Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"),
|
||||
Main => Prompt::new("Select grip to edit array", Accept::POINT)
|
||||
.kw(&["ASsociative", "Base point", "Items", "Angle between", "Fill angle", "ROWs", "Levels", "ROTate items", "eXit"])
|
||||
.default("eXit"),
|
||||
Items => Prompt::new("Enter number of items in array", Accept::NUMBER).default(self.items.to_string()),
|
||||
Between => Prompt::new("Specify the angle between items", Accept::NUMBER).default(fmt(self.between())),
|
||||
Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default(fmt(self.fill)),
|
||||
Rotate => Prompt::new("Rotate arrayed items?", KEYWORDS).kw(&["Yes", "No"]).default(if self.rotate { "Yes" } else { "No" }),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
use PStage::*;
|
||||
if !self.sel.done {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
Ok(self.ready(s))
|
||||
}
|
||||
};
|
||||
}
|
||||
let stage = self.stage;
|
||||
let next = match (stage, &i) {
|
||||
(Main, _) => return self.main(s, i),
|
||||
(Center, Input::Point(p)) => {
|
||||
self.center = *p;
|
||||
Main
|
||||
}
|
||||
(Center, Input::Keyword(k)) if k == "Base point" => CenterBase,
|
||||
(Center, Input::Keyword(_)) => {
|
||||
s.echo("A rotation axis (3D) is not available yet; specify the center point.");
|
||||
Center
|
||||
}
|
||||
(Center, _) => return Err(other("Requires a point or an option keyword.")),
|
||||
(CenterBase | Base, Input::Point(p)) => {
|
||||
self.base = Some(*p);
|
||||
if stage == CenterBase { Center } else { Main }
|
||||
}
|
||||
(CenterBase | Base, Input::Enter) => {
|
||||
self.base = None;
|
||||
if stage == CenterBase { Center } else { Main }
|
||||
}
|
||||
(CenterBase | Base, Input::Keyword(_)) => {
|
||||
s.echo("Key points are not available yet; specify a point.");
|
||||
stage
|
||||
}
|
||||
(Items, _) => match count(&i, self.items, max_count(s, 1, self.objs.len()))? {
|
||||
Some(n) => {
|
||||
self.items = n;
|
||||
Main
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(Between, Input::Enter) => Main,
|
||||
(Between, _) => match angle(&i, self.between(), &s.angle_settings())? {
|
||||
Some(b) => {
|
||||
if b.abs() < 1e-9 {
|
||||
return Err(other("Requires a nonzero angle."));
|
||||
}
|
||||
let n = self.items as f64;
|
||||
let fill = if (b.abs() * n - 360.0).abs() < 1e-9 { 360.0 * b.signum() } else { b * (n - 1.0).max(1.0) };
|
||||
if fill.abs() > 360.0 + 1e-9 {
|
||||
return Err(other("The items would span more than 360 degrees; enter a smaller angle or fewer items."));
|
||||
}
|
||||
self.fill = fill;
|
||||
Main
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(Fill, _) => match angle(&i, self.fill, &s.angle_settings())? {
|
||||
Some(f) => {
|
||||
if f.abs() < 1e-9 || f.abs() > 360.0 + 1e-9 {
|
||||
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
|
||||
}
|
||||
self.fill = f.clamp(-360.0, 360.0);
|
||||
Main
|
||||
}
|
||||
None => stage,
|
||||
},
|
||||
(Rotate, _) => match yes_no(&i, self.rotate) {
|
||||
Some(r) => {
|
||||
self.rotate = r;
|
||||
Main
|
||||
}
|
||||
None => return Err(other("Requires Yes or No.")),
|
||||
},
|
||||
_ => stage,
|
||||
};
|
||||
self.stage = next;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
if !self.sel.done {
|
||||
return Vec::new();
|
||||
}
|
||||
let center = match self.stage {
|
||||
PStage::Center | PStage::CenterBase => cursor,
|
||||
_ => self.center,
|
||||
};
|
||||
preview_copies(s, &self.objs, self.mats(s, center))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polar_angles() {
|
||||
// Full circle: 4 items 90° apart; partial: the last item sits at the fill angle.
|
||||
let a: Vec<Vec2> = polar_mats(Vec2::ZERO, 4, 360.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect();
|
||||
assert_eq!(a.len(), 3);
|
||||
assert!(a[0].near(Vec2::new(0.0, 1.0), 1e-9) && a[2].near(Vec2::new(0.0, -1.0), 1e-9));
|
||||
let b: Vec<Vec2> = polar_mats(Vec2::ZERO, 3, -180.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect();
|
||||
assert!(b[0].near(Vec2::new(0.0, -1.0), 1e-9) && b[1].near(Vec2::new(-1.0, 0.0), 1e-9));
|
||||
// A full clockwise circle does not put the last item on top of the source.
|
||||
let c: Vec<Vec2> = polar_mats(Vec2::ZERO, 4, -360.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect();
|
||||
assert!(c[2].near(Vec2::new(0.0, 1.0), 1e-9));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! AUDIT and RECOVER (File ▸ Drawing Utilities): check a drawing for errors and fix them, and open
|
||||
//! a damaged file with the fixes applied. The checks themselves live in [`crate::audit`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("audit", "Audit", run_audit)
|
||||
.menu(&["File", "Drawing Utilities", "Audit"])
|
||||
.params("{fix?: bool} → {errors, fixed, issues: [{kind, location, handle?, message, fix}], message}")
|
||||
.interactive(|_| Ok(Box::new(Audit))),
|
||||
CommandSpec::new("recover", "Recover...", run_recover)
|
||||
.menu(&["File", "Drawing Utilities", "Recover..."])
|
||||
.params("{path} | {data: base64, name} → {index, errors, fixed, issues, message}")
|
||||
.enabled(always)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(Recover))),
|
||||
]
|
||||
}
|
||||
|
||||
/// Check the current drawing; with `fix`, repair it (one undo step).
|
||||
fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fix = bool_or(p, "fix", false);
|
||||
let mut copy = (*s.doc()?).clone();
|
||||
let issues = crate::audit::repair(&mut copy);
|
||||
// A drawing without errors stays the same object: no undo step, no unsaved changes.
|
||||
if fix && !issues.is_empty() {
|
||||
s.state_mut()?.doc = Arc::new(copy);
|
||||
}
|
||||
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
|
||||
}
|
||||
|
||||
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
|
||||
/// drawing keeps its file name; when anything was fixed it opens with unsaved changes.
|
||||
fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, path) = match (str_param(p, "path"), str_param(p, "data")) {
|
||||
(Some(path), _) => (read_path("recover", path)?, path.to_string(), Some(path.to_string())),
|
||||
(None, Some(data)) => {
|
||||
let bytes = file::base64_decode(data).ok_or_else(|| bad("recover", "invalid base64"))?;
|
||||
(bytes, str_param(p, "name").unwrap_or("Drawing.dxf").to_string(), None)
|
||||
}
|
||||
(None, None) => return Err(bad("recover", "`path` or `data` (base64) is required")),
|
||||
};
|
||||
let hooks = file::io().ok_or_else(|| bad("recover", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(&bytes, &name).map_err(|e| bad("recover", format!("{name} can't be read: {e}")))?;
|
||||
let title = std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.clone());
|
||||
let i = s.open_drawing(d, &title, path);
|
||||
let st = s.state_mut()?;
|
||||
let report = crate::audit::fix(Arc::make_mut(&mut st.doc));
|
||||
if report.issues.is_empty() {
|
||||
// Nothing to fix: the drawing opens unchanged, like OPEN.
|
||||
st.doc = st.saved.clone();
|
||||
}
|
||||
let mut r = report.to_json();
|
||||
r["index"] = json!(i);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_path(cmd: &str, _path: &str) -> Result<Vec<u8>> {
|
||||
Err(bad(cmd, "paths are not available on the web; pass `data`"))
|
||||
}
|
||||
|
||||
const YES_NO: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
|
||||
fn echo_result(s: &mut Session, r: &Value) {
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
for l in m.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AUDIT at the command line: "Fix any errors detected? [Yes/No] <N>:".
|
||||
struct Audit;
|
||||
|
||||
impl Interactive for Audit {
|
||||
fn name(&self) -> &'static str {
|
||||
"AUDIT"
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Fix any errors detected?", YES_NO).kw(&["Yes", "No"]).default("N")
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let fix = match i {
|
||||
Input::Cancel => return Ok(Step::Cancel),
|
||||
Input::Enter => false,
|
||||
Input::Keyword(k) | Input::Text(k) => match self.prompt(s).match_keyword(&k).as_deref() {
|
||||
Some("Yes") => true,
|
||||
Some(_) => false,
|
||||
None => return Err(bad("audit", "answer Yes or No")),
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let r = run_audit(s, &json!({ "fix": fix }))?;
|
||||
echo_result(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// RECOVER at the command line (no file dialog): the file name is typed.
|
||||
struct Recover;
|
||||
|
||||
impl Interactive for Recover {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECOVER"
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Enter name of drawing file to recover", Accept::TEXT)
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Cancel | Input::Enter => Ok(Step::Cancel),
|
||||
Input::Text(t) | Input::Keyword(t) => {
|
||||
let path = t.trim().trim_matches('"');
|
||||
if path.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let r = run_recover(s, &json!({ "path": path }))?;
|
||||
echo_result(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
+369
-152
@@ -1,5 +1,7 @@
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
|
||||
|
||||
mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
|
||||
@@ -11,35 +13,45 @@ use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("block", "Make...", run_block)
|
||||
.menu(&["Draw", "Block", "Make..."])
|
||||
.alias(&["b", "-block", "bmake"])
|
||||
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\", description?}")
|
||||
.alias(&["b", "bmake"])
|
||||
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?}")
|
||||
.interactive(|_| Ok(Box::new(BlockM::default()))),
|
||||
// -BLOCK erases the selected objects once the block is defined.
|
||||
CommandSpec::new("-block", "Create Block", run_dash_block)
|
||||
.alias(&["-b"])
|
||||
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}")
|
||||
.interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))),
|
||||
CommandSpec::new("insert", "Block...", run_insert)
|
||||
.menu(&["Insert", "Block..."])
|
||||
.alias(&["i", "-insert", "ddinsert"])
|
||||
.params("{name, at: [x,y], scale?, rotation? (degrees), attribs?: {TAG: value}, explode?: bool}")
|
||||
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
|
||||
.interactive(|_| Ok(Box::new(InsertM::default()))),
|
||||
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
|
||||
.menu(&["Draw", "Block", "Define Attributes..."])
|
||||
.alias(&["att", "-attdef"])
|
||||
.params("{tag, prompt?, default?, at, height?, invisible?}")
|
||||
.interactive(|_| Ok(Box::new(AttdefM::default()))),
|
||||
CommandSpec::new("attedit", "Single...", run_attedit)
|
||||
.menu(&["Modify", "Object", "Attribute", "Single..."])
|
||||
.alias(&["ate", "eattedit"])
|
||||
.params("{handle, values: {TAG: value}}"),
|
||||
attedit::attedit_spec(),
|
||||
attedit::dash_attedit_spec(),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"),
|
||||
CommandSpec::new("purge", "Purge", purge::run)
|
||||
.alias(&["pu", "-purge"])
|
||||
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
|
||||
.interactive(|_| Ok(Box::new(purge::PurgeM::default()))),
|
||||
CommandSpec::new("blocks.list", "List Blocks", run_list).enabled(has_doc).noundo(),
|
||||
CommandSpec::new("battman", "Block Attribute Manager...", run_battman)
|
||||
.menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."])
|
||||
.params("{name}")
|
||||
.noundo(),
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -47,6 +59,28 @@ fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// The block an entity references, when it is a block reference.
|
||||
fn referenced_block(e: &Entity) -> Option<String> {
|
||||
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
if b.eq_ignore_ascii_case(name) {
|
||||
return true;
|
||||
}
|
||||
if seen.insert(b.to_ascii_uppercase())
|
||||
&& let Some(blk) = d.block(&b)
|
||||
{
|
||||
todo.extend(blk.entities.iter().filter_map(|e| referenced_block(e)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Create a block from entities. Returns the insert handle when converting.
|
||||
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
|
||||
if !valid_block_name(name) {
|
||||
@@ -57,6 +91,12 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
if ents.is_empty() {
|
||||
return Err(bad("block", "no objects selected"));
|
||||
}
|
||||
if references_block(d, &ents, name) {
|
||||
return Err(bad("block", format!("Block \"{name}\" references itself.")));
|
||||
}
|
||||
// Block names are case-insensitive: redefining keeps the existing definition's name.
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
let name = name.as_str();
|
||||
let mut b = Block::new(name);
|
||||
b.base = v3(base);
|
||||
b.description = description.to_string();
|
||||
@@ -81,11 +121,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
|
||||
/// Insert a block reference, filling attributes from `values` (else their defaults).
|
||||
pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation: f64, values: &serde_json::Map<String, Value>) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values)
|
||||
}
|
||||
|
||||
/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block
|
||||
/// coordinates) that lands on `at` instead of the block's own base point.
|
||||
fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option<Vec2>) -> Insert {
|
||||
let mut ins = Insert {
|
||||
block: blk.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(scale, scale, scale),
|
||||
scale: Vec3::new(scale.x, scale.y, scale.x),
|
||||
rotation,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
@@ -93,6 +138,25 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
if let Some(b) = base {
|
||||
let landed = ins.transform(blk.base.xy()).apply(b);
|
||||
ins.insert = v3(at + (at - landed));
|
||||
}
|
||||
ins
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
at: Vec2,
|
||||
scale: Vec2,
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
let m = ins.transform(blk.base.xy());
|
||||
for e in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(ad) = &e.kind {
|
||||
@@ -105,8 +169,15 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation
|
||||
if let EntityKind::Text(tt) = k {
|
||||
t = tt;
|
||||
}
|
||||
t.value = values.get(&ad.tag).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ad.text.value.clone());
|
||||
ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() });
|
||||
t.value = attedit::value_for(values, &ad.tag).unwrap_or_else(|| ad.text.value.clone());
|
||||
ins.attribs.push(Attrib {
|
||||
tag: ad.tag.clone(),
|
||||
text: t,
|
||||
invisible: ad.invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
s.add_entity(EntityKind::Insert(ins))
|
||||
@@ -122,19 +193,27 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &insert_file::json_params(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
||||
let at = point_req("insert", p, "at")?;
|
||||
let scale = f64_or(p, "scale", 1.0);
|
||||
if scale == 0.0 {
|
||||
// `scale`: one factor, or [x, y] for different X and Y scale factors.
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
|
||||
_ => {
|
||||
let f = f64_or(p, "scale", 1.0);
|
||||
Vec2::new(f, f)
|
||||
}
|
||||
};
|
||||
if scale.x == 0.0 || scale.y == 0.0 {
|
||||
return Err(bad("insert", "scale cannot be 0"));
|
||||
}
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let h = insert(s, &name, at, scale, rot, &vals)?;
|
||||
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
|
||||
if bool_or(p, "explode", false) {
|
||||
return s.execute("explode", &json!({ "handles": [h.hex()] }));
|
||||
}
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
Ok(attedit::with_unknown_tags(s, h, &vals, json!({ "handle": h.hex() })))
|
||||
}
|
||||
|
||||
fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind {
|
||||
@@ -156,6 +235,7 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
|
||||
invisible,
|
||||
constant: false,
|
||||
prompt: prompt.into(),
|
||||
props: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,23 +250,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_attedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
|
||||
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let mut n = 0;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = vals.get(&a.tag).and_then(Value::as_str) {
|
||||
a.text.value = v.to_string();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let src = s.doc()?;
|
||||
@@ -196,11 +259,14 @@ fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.text_styles = src.text_styles.clone();
|
||||
d.dim_styles = src.dim_styles.clone();
|
||||
d.blocks = src.blocks.clone();
|
||||
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
||||
let m = Mat3::translate(-base);
|
||||
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
|
||||
// objects and the block's base point for a block (`base` is then in block coordinates).
|
||||
let base = point_param(p, "base");
|
||||
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
|
||||
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
|
||||
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
|
||||
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
|
||||
let m = Mat3::translate(-b.base.xy());
|
||||
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
|
||||
b.entities
|
||||
.iter()
|
||||
.map(|e| {
|
||||
@@ -237,59 +303,6 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn run_purge(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
// Blocks referenced anywhere (including nested references), iterated to a fixed point.
|
||||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let all: Vec<Arc<Entity>> = d.model.iter().cloned().chain(d.layouts.iter().flat_map(|l| l.entities.iter().cloned())).collect();
|
||||
let mut frontier: Vec<String> =
|
||||
all.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
|
||||
for e in &all {
|
||||
if let EntityKind::Dimension(dm) = &e.kind
|
||||
&& let Some(b) = &dm.block
|
||||
{
|
||||
frontier.push(b.clone());
|
||||
}
|
||||
}
|
||||
let mut guard = 0;
|
||||
while let Some(n) = frontier.pop() {
|
||||
guard += 1;
|
||||
if guard > 100_000 || !used.insert(n.to_ascii_uppercase()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = d.block(&n) {
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
frontier.push(i.block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let before_b = d.blocks.len();
|
||||
d.blocks.retain(|k, _| used.contains(&k.to_ascii_uppercase()));
|
||||
let blocks = before_b - d.blocks.len();
|
||||
let used_layers: std::collections::HashSet<String> = all.iter().map(|e| e.common.layer.to_ascii_lowercase()).collect();
|
||||
let cur = d.header.str("CLAYER", "0").to_ascii_lowercase();
|
||||
let before_l = d.layers.len();
|
||||
d.layers.retain(|l| {
|
||||
l.name == "0"
|
||||
|| l.name.eq_ignore_ascii_case("Defpoints")
|
||||
|| l.name.to_ascii_lowercase() == cur
|
||||
|| used_layers.contains(&l.name.to_ascii_lowercase())
|
||||
});
|
||||
let layers = before_l - d.layers.len();
|
||||
let used_lt: std::collections::HashSet<String> =
|
||||
all.iter().map(|e| e.common.linetype.to_ascii_lowercase()).chain(d.layers.iter().map(|l| l.linetype.to_ascii_lowercase())).collect();
|
||||
let before_t = d.linetypes.len();
|
||||
d.linetypes.retain(|l| {
|
||||
["byblock", "bylayer", "continuous"].contains(&l.name.to_ascii_lowercase().as_str()) || used_lt.contains(&l.name.to_ascii_lowercase())
|
||||
});
|
||||
let linetypes = before_t - d.linetypes.len();
|
||||
Ok(
|
||||
json!({ "blocks": blocks, "layers": layers, "linetypes": linetypes, "message": format!("{blocks} blocks, {layers} layers, {linetypes} linetypes purged.") }),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
@@ -316,8 +329,23 @@ fn run_battman(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
/// -BLOCK's JSON form: BLOCK with the selection erased unless `keep` says otherwise.
|
||||
fn run_dash_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.entry("keep").or_insert_with(|| json!("delete"));
|
||||
}
|
||||
run_block(s, &p)
|
||||
}
|
||||
|
||||
/// BLOCK / -BLOCK at the command line. BLOCK converts the selection to a reference to the new
|
||||
/// block (the dialog's default); -BLOCK erases it.
|
||||
#[derive(Default)]
|
||||
struct BlockM {
|
||||
/// -BLOCK: erase the selection.
|
||||
dash: bool,
|
||||
/// A name already in use, waiting for the answer to "Redefine it?".
|
||||
existing: Option<String>,
|
||||
name: Option<String>,
|
||||
base: Option<Vec2>,
|
||||
sel: SelectPhase,
|
||||
@@ -329,13 +357,22 @@ impl Interactive for BlockM {
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (&self.name, self.base) {
|
||||
(None, _) => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
|
||||
(None, _) => match &self.existing {
|
||||
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N"),
|
||||
None => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
|
||||
},
|
||||
(Some(_), None) => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["Annotative"]),
|
||||
_ => self.sel.prompt(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (&self.name, self.base) {
|
||||
(None, _) if self.existing.is_some() => {
|
||||
// Yes redefines the block; No (the default) asks for another name.
|
||||
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y'));
|
||||
self.name = self.existing.take().filter(|_| yes);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, _) => {
|
||||
if let Input::Text(t) = i {
|
||||
let n = t.trim().to_string();
|
||||
@@ -344,6 +381,8 @@ impl Interactive for BlockM {
|
||||
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
|
||||
} else if !valid_block_name(&n) {
|
||||
s.echo("Invalid block name.");
|
||||
} else if s.doc()?.block(&n).is_some() {
|
||||
self.existing = Some(n);
|
||||
} else {
|
||||
self.name = Some(n);
|
||||
}
|
||||
@@ -373,20 +412,129 @@ impl Interactive for BlockM {
|
||||
impl BlockM {
|
||||
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) };
|
||||
make_block(s, &name, base, hs, "convert", "")?;
|
||||
// A definition that can't be made (e.g. one that would reference itself) ends the command.
|
||||
let keep = if self.dash { "delete" } else { "convert" };
|
||||
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
|
||||
s.echo(e.to_string());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
s.echo(format!("Block \"{name}\" defined with {} object(s).", hs.len()));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
mod insert_file;
|
||||
|
||||
/// Where the command-line INSERT is in its prompt sequence.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum InsStage {
|
||||
#[default]
|
||||
Name,
|
||||
/// A file whose block already exists: redefine it from the file?
|
||||
Redefine,
|
||||
Point,
|
||||
/// Basepoint: a new base point, picked on the block shown at its definition position.
|
||||
BasePoint,
|
||||
/// Scale preset before the insertion point.
|
||||
PresetScale,
|
||||
/// Rotate preset before the insertion point.
|
||||
PresetRotation,
|
||||
XScale,
|
||||
Corner,
|
||||
YScale,
|
||||
Rotation,
|
||||
Attribs,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InsertM {
|
||||
stage: InsStage,
|
||||
name: Option<String>,
|
||||
at: Option<Vec2>,
|
||||
scale: Option<f64>,
|
||||
base: Option<Vec2>,
|
||||
x_scale: Option<f64>,
|
||||
scale: Option<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
attdefs: Vec<(String, String, String)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
file: Option<insert_file::FileRef>,
|
||||
}
|
||||
|
||||
impl InsertM {
|
||||
/// After the insertion point or a scale/rotation answer: the next prompt still needed.
|
||||
fn advance(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.stage = if self.scale.is_none() {
|
||||
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
|
||||
} else if self.rotation.is_none() {
|
||||
InsStage::Rotation
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
if self.stage == InsStage::Attribs
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
|
||||
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
|
||||
let Some(b) = s.doc()?.block(name).cloned() else {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
return Ok(());
|
||||
};
|
||||
self.attdefs = if self.explode {
|
||||
Vec::new()
|
||||
} else {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let EntityKind::AttDef(a) = &e.kind {
|
||||
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.doc_mut()?.header.set_str("INSNAME", &b.name);
|
||||
self.name = Some(b.name.clone());
|
||||
self.stage = InsStage::Point;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Define the block from a file, then choose it; a file that can't be read is reported.
|
||||
fn choose_file(&mut self, s: &mut Session, f: &insert_file::FileRef) -> Result<()> {
|
||||
match f.define(s) {
|
||||
Ok(name) => self.choose(s, &name),
|
||||
Err(e) => {
|
||||
s.echo(e.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
|
||||
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
|
||||
let d = p - a;
|
||||
if d.x.abs() < 1e-12 || d.y.abs() < 1e-12 {
|
||||
return Err(EngineError::Other("The corner must not be level with the insertion point.".into()));
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
}
|
||||
|
||||
fn nonzero(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
|
||||
}
|
||||
|
||||
impl Interactive for InsertM {
|
||||
@@ -394,25 +542,36 @@ impl Interactive for InsertM {
|
||||
"INSERT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default();
|
||||
match (&self.name, self.at, self.scale, self.rotation) {
|
||||
(None, ..) => {
|
||||
let at = self.at.unwrap_or_default();
|
||||
match self.stage {
|
||||
InsStage::Name => {
|
||||
let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default();
|
||||
let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]);
|
||||
if last.is_empty() { p } else { p.default(last) }
|
||||
}
|
||||
(Some(_), None, ..) => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
|
||||
(Some(_), Some(a), None, _) => Prompt::new("Enter scale factor", Accept::POINT_OR_NUMBER).default("1").base(a),
|
||||
(Some(_), Some(a), Some(_), None) => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(a),
|
||||
_ => {
|
||||
let (tag, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let _ = tag;
|
||||
InsStage::Redefine => {
|
||||
let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default();
|
||||
Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N")
|
||||
}
|
||||
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
|
||||
InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT),
|
||||
InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"),
|
||||
InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
|
||||
InsStage::XScale => {
|
||||
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at)
|
||||
}
|
||||
InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at),
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (&self.name, self.at, self.scale, self.rotation) {
|
||||
(None, ..) => {
|
||||
match self.stage {
|
||||
InsStage::Name => {
|
||||
let n = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Enter => s.doc()?.header.str("INSNAME", ""),
|
||||
@@ -423,77 +582,133 @@ impl Interactive for InsertM {
|
||||
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let Some(b) = s.doc()?.block(&n).cloned() else {
|
||||
s.echo(format!("Block \"{n}\" not found."));
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
self.attdefs = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let EntityKind::AttDef(a) = &e.kind {
|
||||
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.doc_mut()?.header.set_str("INSNAME", &b.name);
|
||||
self.name = Some(b.name.clone());
|
||||
}
|
||||
(Some(_), None, ..) => {
|
||||
if let Input::Point(p) = i {
|
||||
self.at = Some(p);
|
||||
// `*name`: insert exploded.
|
||||
self.explode = n.starts_with('*');
|
||||
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
|
||||
match insert_file::file_ref(s, &n) {
|
||||
Some(f) if f.ask_redefine(s) => {
|
||||
self.file = Some(f);
|
||||
self.stage = InsStage::Redefine;
|
||||
}
|
||||
Some(f) => self.choose_file(s, &f)?,
|
||||
None => self.choose(s, &n)?,
|
||||
}
|
||||
}
|
||||
(Some(_), Some(a), None, _) => {
|
||||
self.scale = Some(match i {
|
||||
Input::Point(p) => a.dist(p).max(1e-9),
|
||||
Input::Text(t) => number(&t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))?,
|
||||
_ => 1.0,
|
||||
});
|
||||
InsStage::Redefine => {
|
||||
// Yes defines the block from the file; No (the default) inserts the existing block.
|
||||
let Some(f) = self.file.take() else { return Ok(Step::Continue) };
|
||||
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y'));
|
||||
if yes {
|
||||
self.choose_file(s, &f)?;
|
||||
} else {
|
||||
self.choose(s, &f.name)?;
|
||||
}
|
||||
}
|
||||
(Some(_), Some(a), Some(_), None) => {
|
||||
InsStage::Point => match i {
|
||||
Input::Point(p) => {
|
||||
self.at = Some(p);
|
||||
return self.advance(s);
|
||||
}
|
||||
Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint,
|
||||
Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale,
|
||||
Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation,
|
||||
_ => {}
|
||||
},
|
||||
InsStage::BasePoint => {
|
||||
if let Input::Point(p) = i {
|
||||
self.base = Some(p);
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
}
|
||||
InsStage::PresetScale => {
|
||||
let f = match i {
|
||||
Input::Text(t) => nonzero(&t)?,
|
||||
Input::Enter => 1.0,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.scale = Some(Vec2::new(f, f));
|
||||
// Exploded insertion asks its one scale factor after the insertion point.
|
||||
if self.at.is_some() {
|
||||
return self.advance(s);
|
||||
}
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
InsStage::PresetRotation => {
|
||||
self.rotation = Some(match i {
|
||||
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
||||
Input::Enter => 0.0,
|
||||
_ => return Ok(Step::Continue),
|
||||
});
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
InsStage::XScale => match i {
|
||||
Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner,
|
||||
Input::Point(p) => {
|
||||
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
Input::Text(t) => {
|
||||
self.x_scale = Some(nonzero(&t)?);
|
||||
self.stage = InsStage::YScale;
|
||||
}
|
||||
Input::Enter => {
|
||||
self.x_scale = Some(1.0);
|
||||
self.stage = InsStage::YScale;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InsStage::Corner => {
|
||||
if let Input::Point(p) = i {
|
||||
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::YScale => {
|
||||
let x = self.x_scale.unwrap_or(1.0);
|
||||
let y = match i {
|
||||
Input::Text(t) => nonzero(&t)?,
|
||||
Input::Enter => x,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.scale = Some(Vec2::new(x, y));
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rotation => {
|
||||
let a = self.at.unwrap_or_default();
|
||||
self.rotation = Some(match i {
|
||||
Input::Point(p) => a.angle_to(p),
|
||||
Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
||||
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
||||
_ => 0.0,
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
_ => {
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => default,
|
||||
};
|
||||
self.values.insert(tag, Value::String(v));
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
if let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
{
|
||||
insert(s, &n, a, sc, r, &self.values)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let (Some(n), Ok(d)) = (&self.name, s.doc()) else { return Vec::new() };
|
||||
let Some(b) = d.block(n) else { return Vec::new() };
|
||||
// Picking a new base point: the block sits at its definition position.
|
||||
if self.stage == InsStage::BasePoint {
|
||||
return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))];
|
||||
}
|
||||
let at = self.at.unwrap_or(c);
|
||||
let sc = self.scale.unwrap_or(if self.at.is_some() { at.dist(c).max(1e-9) } else { 1.0 });
|
||||
let rot = self.rotation.unwrap_or(if self.scale.is_some() { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(Insert {
|
||||
block: b.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(sc, sc, sc),
|
||||
rotation: rot,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
})]
|
||||
let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) {
|
||||
(InsStage::XScale | InsStage::Corner, _) => Self::corner(at, c).unwrap_or(Vec2::new(1.0, 1.0)),
|
||||
(_, Some(x)) => Vec2::new(x, x),
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,3 +757,5 @@ impl Interactive for AttdefM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
mod attribs;
|
||||
@@ -0,0 +1,539 @@
|
||||
//! ATTEDIT / EATTEDIT: one block reference's attribute values, asked for one after another.
|
||||
//! -ATTEDIT: the attributes picked by block name, tag and value specifications, edited one at a
|
||||
//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.
|
||||
|
||||
use cadcraft_doc::color::Color;
|
||||
use cadcraft_doc::{AttribProps, Common, EntityKind, Handle};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::qselect::wildcard;
|
||||
use crate::cmd::{CommandSpec, bad, str_param, targets};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Longest attribute value a string replacement may produce (bytes).
|
||||
const MAX_VALUE: usize = 65_536;
|
||||
|
||||
pub(super) fn attedit_spec() -> CommandSpec {
|
||||
CommandSpec::new("attedit", "Single...", run)
|
||||
.menu(&["Modify", "Object", "Attribute", "Single..."])
|
||||
.alias(&["ate", "eattedit"])
|
||||
.params("{handle, values: {TAG: value} (tags in any case)} → {changed, unknownTags?}")
|
||||
.interactive(|_| Ok(Box::new(AtteditM::default())))
|
||||
}
|
||||
|
||||
pub(super) fn dash_attedit_spec() -> CommandSpec {
|
||||
CommandSpec::new("-attedit", "Global", run_global)
|
||||
.menu(&["Modify", "Object", "Attribute", "Global"])
|
||||
.alias(&["-ate", "atte"])
|
||||
.params("{find, replace?, block?, tag?, value? (wildcard specifications, default *), handles? (default: every block reference in the current space)} → {changed}")
|
||||
.interactive(|_| Ok(Box::new(DashAtteditM::default())))
|
||||
}
|
||||
|
||||
/// The value `values` gives for `tag`, matching the tag in any case. Numbers and booleans count
|
||||
/// as their text.
|
||||
pub(super) fn value_for(values: &Map<String, Value>, tag: &str) -> Option<String> {
|
||||
let v = values.get(tag).or_else(|| values.iter().find(|(k, _)| k.eq_ignore_ascii_case(tag)).map(|(_, v)| v))?;
|
||||
match v {
|
||||
Value::String(t) => Some(t.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` with `unknownTags`: the keys of `values` that block reference `h` has no attribute for
|
||||
/// (left out when there are none).
|
||||
pub(super) fn with_unknown_tags(s: &Session, h: Handle, values: &Map<String, Value>, mut r: Value) -> Value {
|
||||
let tags: Vec<&str> = match s.doc().ok().and_then(|d| d.entity(h)).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => i.attribs.iter().map(|a| a.tag.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let unknown: Vec<&String> = values.keys().filter(|k| !tags.iter().any(|t| t.eq_ignore_ascii_case(k))).collect();
|
||||
if !unknown.is_empty()
|
||||
&& let Some(o) = r.as_object_mut()
|
||||
{
|
||||
o.insert("unknownTags".into(), json!(unknown));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
|
||||
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let mut n = 0;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = value_for(&vals, &a.tag) {
|
||||
a.text.value = v;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(with_unknown_tags(s, h, &vals, json!({ "changed": n })))
|
||||
}
|
||||
|
||||
/// A block name, tag or value specification: comma-separated wildcard patterns, any case. For
|
||||
/// values, `\` matches an empty value.
|
||||
fn spec_matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| if p.trim() == "\\" { text.is_empty() } else { wildcard(p.trim(), text) })
|
||||
}
|
||||
|
||||
/// Specifications choosing attributes: block name, tag, value.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Specs {
|
||||
block: String,
|
||||
tag: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Default for Specs {
|
||||
fn default() -> Self {
|
||||
Specs { block: "*".into(), tag: "*".into(), value: "*".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The attributes of block references `hs` that match `specs`, as (block reference, attribute
|
||||
/// index). Invisible attributes count only with `invisible` (or while ATTMODE shows them).
|
||||
fn matching(s: &Session, hs: &[Handle], specs: &Specs, invisible: bool) -> Result<Vec<(Handle, usize)>> {
|
||||
let d = s.doc()?;
|
||||
let invisible = invisible || d.header.i64("ATTMODE", 1) == 2;
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
|
||||
if !spec_matches(&specs.block, &ins.block) {
|
||||
continue;
|
||||
}
|
||||
for (k, a) in ins.attribs.iter().enumerate() {
|
||||
if !a.constant && (invisible || !a.invisible) && spec_matches(&specs.tag, &a.tag) && spec_matches(&specs.value, &a.text.value) {
|
||||
out.push((*h, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every object in the current space.
|
||||
fn all_in_space(s: &Session) -> Result<Vec<Handle>> {
|
||||
let d = s.doc()?;
|
||||
Ok(d.space(&s.space()).map(|st| st.iter().map(|e| e.handle).collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Change attribute `k` of block reference `h`; returns whether its value changed.
|
||||
fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option<String>) -> Result<bool> {
|
||||
let mut changed = false;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind
|
||||
&& let Some(a) = ins.attribs.get_mut(k)
|
||||
&& let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)
|
||||
{
|
||||
a.text.value = v;
|
||||
changed = true;
|
||||
}
|
||||
})?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Change the properties set on attribute `k` of block reference `h`.
|
||||
fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind
|
||||
&& let Some(a) = ins.attribs.get_mut(k)
|
||||
{
|
||||
f(&mut a.props);
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The layer and colour attribute `k` of block reference `h` is drawn with: its own, else its
|
||||
/// definition's (layer 0 is the reference's layer).
|
||||
fn current_props(s: &Session, (h, k): (Handle, usize)) -> Option<(String, Color)> {
|
||||
let d = s.doc().ok()?;
|
||||
let e = d.entity(h)?;
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let a = ins.attribs.get(k)?;
|
||||
let def = d
|
||||
.block(&ins.block)
|
||||
.and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag))));
|
||||
let c = a.props.over(&def.map(|de| de.common.clone()).unwrap_or_else(Common::default));
|
||||
let layer = if c.layer == "0" { e.common.layer.clone() } else { c.layer };
|
||||
Some((layer, c.color))
|
||||
}
|
||||
|
||||
/// Replace every `find` in `value` with `with`, unless the result would be too long.
|
||||
fn replaced(value: &str, find: &str, with: &str) -> Option<String> {
|
||||
if find.is_empty() || !value.contains(find) {
|
||||
return None;
|
||||
}
|
||||
let n = value.matches(find).count();
|
||||
let len = value.len().checked_sub(n.checked_mul(find.len())?)?.checked_add(n.checked_mul(with.len())?)?;
|
||||
(len <= MAX_VALUE).then(|| value.replace(find, with))
|
||||
}
|
||||
|
||||
/// Replace `find` with `with` in the given attributes; returns how many changed.
|
||||
fn replace_all(s: &mut Session, items: &[(Handle, usize)], find: &str, with: &str) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for it in items {
|
||||
if edit(s, *it, |v| replaced(v, find, with))? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn run_global(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad("-attedit", "`find` (a non-empty string) is required"))?;
|
||||
let with = str_param(p, "replace").unwrap_or("");
|
||||
let spec = |k| str_param(p, k).unwrap_or("*").to_string();
|
||||
let specs = Specs { block: spec("block"), tag: spec("tag"), value: spec("value") };
|
||||
let hs = if p.get("handles").is_some() || p.get("handle").is_some() { targets(s, p)? } else { all_in_space(s)? };
|
||||
let items = matching(s, &hs, &specs, true)?;
|
||||
let (find, with) = (find.to_string(), with.to_string());
|
||||
Ok(json!({ "changed": replace_all(s, &items, &find, &with)? }))
|
||||
}
|
||||
|
||||
// ---------------- ATTEDIT / EATTEDIT ----------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct AtteditM {
|
||||
block: Option<Handle>,
|
||||
/// Index, tag and current value of each editable attribute of the picked block reference.
|
||||
attribs: Vec<(usize, String, String)>,
|
||||
/// The values answered so far, in attribute order.
|
||||
values: Vec<String>,
|
||||
}
|
||||
|
||||
impl AtteditM {
|
||||
fn pick(&mut self, s: &Session, hs: &[Handle]) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let mut msg = "Select a block reference.";
|
||||
for h in hs {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
|
||||
let atts: Vec<(usize, String, String)> =
|
||||
ins.attribs.iter().enumerate().filter(|(_, a)| !a.constant).map(|(k, a)| (k, a.tag.clone(), a.text.value.clone())).collect();
|
||||
if atts.is_empty() {
|
||||
msg = "That block reference has no editable attributes.";
|
||||
continue;
|
||||
}
|
||||
self.block = Some(*h);
|
||||
self.attribs = atts;
|
||||
return Ok(());
|
||||
}
|
||||
Err(EngineError::Other(msg.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AtteditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTEDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.attribs.get(self.values.len()) {
|
||||
Some((_, tag, cur)) if self.block.is_some() => Prompt::new(format!("Enter value for {tag}"), Accept::TEXT).default(cur.clone()),
|
||||
_ => Prompt::new("Select block reference", Accept::SELECT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(h) = self.block else {
|
||||
return match i {
|
||||
Input::Pick(hs) => self.pick(s, &hs).map(|_| Step::Continue),
|
||||
Input::Enter => Ok(Step::Done),
|
||||
Input::Text(_) | Input::Keyword(_) => Err(EngineError::Other("Select a block reference.".into())),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
};
|
||||
let cur = self.attribs.get(self.values.len()).map(|a| a.2.clone()).unwrap_or_default();
|
||||
self.values.push(match i {
|
||||
Input::Text(t) => t,
|
||||
Input::Enter => cur,
|
||||
_ => return Ok(Step::Continue),
|
||||
});
|
||||
if self.values.len() < self.attribs.len() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// All answered: apply them together (one undo step).
|
||||
for ((k, _, _), v) in self.attribs.iter().zip(&self.values) {
|
||||
edit(s, (h, *k), |_| Some(v.clone()))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- -ATTEDIT ----------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
OneAtATime,
|
||||
VisibleOnly,
|
||||
BlockSpec,
|
||||
TagSpec,
|
||||
ValueSpec,
|
||||
Select,
|
||||
/// One at a time: what to do with the current attribute.
|
||||
Option,
|
||||
ValueKind,
|
||||
NewValue,
|
||||
Layer,
|
||||
Color,
|
||||
TrueColor,
|
||||
Find,
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DashAtteditM {
|
||||
stage: Stage,
|
||||
one_at_a_time: bool,
|
||||
visible_only: bool,
|
||||
specs: Specs,
|
||||
sel: SelectPhase,
|
||||
items: Vec<(Handle, usize)>,
|
||||
/// The attribute being edited one at a time (index into `items`).
|
||||
cur: usize,
|
||||
find: String,
|
||||
}
|
||||
|
||||
/// A colour name, number or `r,g,b` (typed numbers are answers, not distances).
|
||||
const NAME: Accept = Accept { number: true, ..Accept::TEXT };
|
||||
|
||||
fn invalid_keyword() -> EngineError {
|
||||
EngineError::Other("Invalid option keyword.".into())
|
||||
}
|
||||
|
||||
impl DashAtteditM {
|
||||
fn yes(i: &Input) -> Result<bool> {
|
||||
match i {
|
||||
Input::Keyword(k) => Ok(k == "Yes"),
|
||||
Input::Enter => Ok(true),
|
||||
_ => Err(invalid_keyword()),
|
||||
}
|
||||
}
|
||||
/// The attributes are chosen: start editing them.
|
||||
fn chosen(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
self.items = matching(s, hs, &self.specs, !self.visible_only && !self.one_at_a_time)?;
|
||||
s.set_selection(Vec::new());
|
||||
let n = self.items.len();
|
||||
s.echo(format!("{n} attribute{} selected.", if n == 1 { "" } else { "s" }));
|
||||
if n == 0 {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
if self.one_at_a_time {
|
||||
self.cur = 0;
|
||||
self.show_current(s);
|
||||
self.stage = Stage::Option;
|
||||
} else {
|
||||
self.stage = Stage::Find;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn show_current(&self, s: &mut Session) {
|
||||
let Some((h, k)) = self.items.get(self.cur).copied() else { return };
|
||||
if let Ok(d) = s.doc()
|
||||
&& let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind)
|
||||
&& let Some(a) = ins.attribs.get(k)
|
||||
{
|
||||
let line = format!("{} = {}", a.tag, a.text.value);
|
||||
s.echo(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for DashAtteditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-ATTEDIT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = self.items.get(self.cur).and_then(|it| current_props(s, *it));
|
||||
match self.stage {
|
||||
Stage::OneAtATime => Prompt::new("Edit attributes one at a time?", KW).kw(&["Yes", "No"]).default("Y"),
|
||||
Stage::VisibleOnly => Prompt::new("Edit only attributes visible on screen?", KW).kw(&["Yes", "No"]).default("Y"),
|
||||
Stage::BlockSpec => Prompt::new("Enter block name specification", Accept::TEXT).default("*"),
|
||||
Stage::TagSpec => Prompt::new("Enter attribute tag specification", Accept::TEXT).default("*"),
|
||||
Stage::ValueSpec => Prompt::new("Enter attribute value specification", Accept::TEXT).default("*"),
|
||||
Stage::Select => Prompt::new("Select attributes", Accept::SELECT),
|
||||
Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Layer", "Color", "Next"]).default("N"),
|
||||
Stage::ValueKind => Prompt::new("Enter type of value modification", KW).kw(&["Change", "Replace"]).default("R"),
|
||||
Stage::NewValue => Prompt::new("Enter new attribute value", Accept::TEXT),
|
||||
Stage::Layer => {
|
||||
let p = Prompt::new("Enter new layer name", Accept::TEXT);
|
||||
match cur {
|
||||
Some((layer, _)) => p.default(layer),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
Stage::Color => {
|
||||
let p = Prompt::new("Enter new color", NAME).kw(&["Truecolor", "COlorbook"]);
|
||||
match cur {
|
||||
Some((_, c)) => p.default(c.name()),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
Stage::TrueColor => Prompt::new("Red, Green, Blue", NAME),
|
||||
Stage::Find => Prompt::new("Enter string to change", Accept::TEXT),
|
||||
Stage::Replace => Prompt::new("Enter new string", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
// Free-text answers; Enter gives "" (the specifications then take their default "*").
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.clone()),
|
||||
Input::Enter => Some(String::new()),
|
||||
_ => None,
|
||||
};
|
||||
match self.stage {
|
||||
Stage::OneAtATime => {
|
||||
self.one_at_a_time = Self::yes(&i)?;
|
||||
self.stage = if self.one_at_a_time {
|
||||
Stage::BlockSpec
|
||||
} else {
|
||||
s.echo("Performing global editing of attribute values.");
|
||||
Stage::VisibleOnly
|
||||
};
|
||||
}
|
||||
Stage::VisibleOnly => {
|
||||
self.visible_only = Self::yes(&i)?;
|
||||
self.stage = Stage::BlockSpec;
|
||||
}
|
||||
Stage::BlockSpec | Stage::TagSpec | Stage::ValueSpec => {
|
||||
let Some(t) = text else { return Ok(Step::Continue) };
|
||||
let t = if t.trim().is_empty() { "*".to_string() } else { t.trim().to_string() };
|
||||
match self.stage {
|
||||
Stage::BlockSpec => {
|
||||
self.specs.block = t;
|
||||
self.stage = Stage::TagSpec;
|
||||
}
|
||||
Stage::TagSpec => {
|
||||
self.specs.tag = t;
|
||||
self.stage = Stage::ValueSpec;
|
||||
}
|
||||
_ => {
|
||||
self.specs.value = t;
|
||||
if !self.one_at_a_time && !self.visible_only {
|
||||
let hs = all_in_space(s)?;
|
||||
return self.chosen(s, &hs);
|
||||
}
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
}
|
||||
}
|
||||
Stage::Select => {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => self.chosen(s, &[]),
|
||||
SelOutcome::Done(hs) => self.chosen(s, &hs),
|
||||
};
|
||||
}
|
||||
Stage::Option => match &i {
|
||||
Input::Keyword(k) if k == "Value" => self.stage = Stage::ValueKind,
|
||||
Input::Keyword(k) if k == "Layer" => self.stage = Stage::Layer,
|
||||
Input::Keyword(k) if k == "Color" => self.stage = Stage::Color,
|
||||
Input::Keyword(_) | Input::Enter => {
|
||||
self.cur += 1;
|
||||
if self.cur >= self.items.len() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.show_current(s);
|
||||
}
|
||||
Input::Text(t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let later = ["Position", "Height", "Angle", "Style"];
|
||||
return match later.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) {
|
||||
Some(k) => Err(EngineError::Other(format!("{k} is not available yet."))),
|
||||
None => Err(invalid_keyword()),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Stage::ValueKind => match &i {
|
||||
Input::Keyword(k) if k == "Change" => self.stage = Stage::Find,
|
||||
Input::Keyword(_) | Input::Enter => self.stage = Stage::NewValue,
|
||||
_ => return Err(invalid_keyword()),
|
||||
},
|
||||
Stage::NewValue => {
|
||||
let Some(v) = text else { return Ok(Step::Continue) };
|
||||
if let Some(it) = self.items.get(self.cur).copied() {
|
||||
edit(s, it, |_| Some(v))?;
|
||||
}
|
||||
self.show_current(s);
|
||||
self.stage = Stage::Option;
|
||||
}
|
||||
Stage::Layer => {
|
||||
let Some(t) = text else { return Ok(Step::Continue) };
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
let name = s.doc()?.layer(t).map(|l| l.name.clone()).ok_or_else(|| EngineError::Other(format!("Cannot find layer \"{t}\".")))?;
|
||||
if let Some(it) = self.items.get(self.cur).copied() {
|
||||
set_props(s, it, |p| p.layer = Some(name))?;
|
||||
}
|
||||
}
|
||||
self.stage = Stage::Option;
|
||||
}
|
||||
Stage::Color | Stage::TrueColor => {
|
||||
if self.stage == Stage::Color
|
||||
&& let Input::Keyword(k) = &i
|
||||
{
|
||||
if k == "Truecolor" {
|
||||
self.stage = Stage::TrueColor;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return Err(EngineError::Other("Color books are not available yet.".into()));
|
||||
}
|
||||
let Some(t) = text else { return Ok(Step::Continue) };
|
||||
if !t.trim().is_empty() {
|
||||
let c = match (self.stage, Color::parse(&t)) {
|
||||
(Stage::TrueColor, Some(c @ Color::True(_))) | (Stage::Color, Some(c)) => c,
|
||||
(Stage::TrueColor, _) => return Err(EngineError::Other("Enter three values from 0 to 255, separated by commas.".into())),
|
||||
_ => return Err(EngineError::Other("Enter a color name or a number from 0 to 256.".into())),
|
||||
};
|
||||
if let Some(it) = self.items.get(self.cur).copied() {
|
||||
set_props(s, it, |p| p.color = Some(c))?;
|
||||
}
|
||||
}
|
||||
self.stage = Stage::Option;
|
||||
}
|
||||
Stage::Find => match text {
|
||||
Some(t) if !t.is_empty() => {
|
||||
self.find = t;
|
||||
self.stage = Stage::Replace;
|
||||
}
|
||||
Some(_) => return Err(EngineError::Other("Requires the string to change.".into())),
|
||||
None => {}
|
||||
},
|
||||
Stage::Replace => {
|
||||
let Some(with) = text else { return Ok(Step::Continue) };
|
||||
let find = std::mem::take(&mut self.find);
|
||||
if self.one_at_a_time {
|
||||
let items: Vec<(Handle, usize)> = self.items.get(self.cur).copied().into_iter().collect();
|
||||
replace_all(s, &items, &find, &with)?;
|
||||
self.show_current(s);
|
||||
self.stage = Stage::Option;
|
||||
} else {
|
||||
let n = replace_all(s, &self.items, &find, &with)?;
|
||||
s.echo(format!("{n} attribute{} changed.", if n == 1 { "" } else { "s" }));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacement_is_bounded_and_specs_take_lists() {
|
||||
assert_eq!(replaced("a-b-c", "-", "+").as_deref(), Some("a+b+c"));
|
||||
assert_eq!(replaced("abc", "x", "y"), None);
|
||||
assert_eq!(replaced("aaaa", "a", &"x".repeat(MAX_VALUE)), None);
|
||||
assert!(spec_matches("door,WIN*", "window"));
|
||||
assert!(spec_matches("\\", ""));
|
||||
assert!(!spec_matches("\\", "x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! ATTDISP (attribute display: ATTMODE) and ATTSYNC (block references brought in line with
|
||||
//! their block's attribute definitions).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, Handle, Insert};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::qselect::wildcard;
|
||||
use crate::cmd::{CommandSpec, bad, str_param, targets};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn attdisp_spec() -> CommandSpec {
|
||||
CommandSpec::new("attdisp", "Attribute Display", run_attdisp)
|
||||
.params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}")
|
||||
.interactive(|_| Ok(Box::new(AttdispM)))
|
||||
}
|
||||
|
||||
pub(super) fn attsync_spec() -> CommandSpec {
|
||||
CommandSpec::new("attsync", "Synchronize Attributes", run_attsync)
|
||||
.menu(&["Modify", "Object", "Attribute", "Synchronize Attributes"])
|
||||
.params("{name} or {handle} (a reference of the block) → {block, references}")
|
||||
.interactive(|_| Ok(Box::new(AttsyncM::default())))
|
||||
}
|
||||
|
||||
// ---------------- ATTDISP ----------------
|
||||
|
||||
/// ATTDISP's keyword for an ATTMODE value.
|
||||
fn attdisp_keyword(mode: i64) -> &'static str {
|
||||
match mode {
|
||||
0 => "OFF",
|
||||
2 => "ON",
|
||||
_ => "Normal",
|
||||
}
|
||||
}
|
||||
|
||||
/// ATTMODE for an ATTDISP keyword: Normal shows the visible attributes, ON all, OFF none.
|
||||
fn attmode(keyword: &str) -> Option<i64> {
|
||||
match keyword.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" | "n" => Some(1),
|
||||
"on" => Some(2),
|
||||
"off" => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_attmode(s: &mut Session, mode: i64) -> Result<Value> {
|
||||
s.doc_mut()?.header.set_i64("ATTMODE", mode);
|
||||
Ok(json!({ "attmode": mode }))
|
||||
}
|
||||
|
||||
fn run_attdisp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").and_then(attmode).ok_or_else(|| bad("attdisp", "`mode` (\"normal\", \"on\" or \"off\") is required"))?;
|
||||
set_attmode(s, mode)
|
||||
}
|
||||
|
||||
struct AttdispM;
|
||||
|
||||
impl Interactive for AttdispM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDISP"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = s.doc().map(|d| d.header.i64("ATTMODE", 1)).unwrap_or(1);
|
||||
Prompt::new("Enter attribute visibility setting", KW).kw(&["Normal", "ON", "OFF"]).default(attdisp_keyword(cur))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Keyword(k) | Input::Text(k) => {
|
||||
let mode = attmode(&k).ok_or_else(|| EngineError::Other("Invalid option keyword.".into()))?;
|
||||
set_attmode(s, mode)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ATTSYNC ----------------
|
||||
|
||||
/// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and
|
||||
/// shown as defined, keeping the values of attributes whose tag (any case) is still defined.
|
||||
/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD.
|
||||
fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
defs.iter()
|
||||
.map(|ad| {
|
||||
let mut k = EntityKind::Text(ad.text.clone());
|
||||
k.transform(&m);
|
||||
let mut t = if let EntityKind::Text(t) = k { t } else { ad.text.clone() };
|
||||
t.value = ins
|
||||
.attribs
|
||||
.iter()
|
||||
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
|
||||
.map(|a| a.text.value.clone())
|
||||
.unwrap_or_else(|| ad.text.value.clone());
|
||||
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The references of `name` among `ents`.
|
||||
fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Vec<Handle> {
|
||||
ents.filter(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name))).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs: Vec<&Attrib> = blk
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if !a.constant => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
let update = |e: &mut Entity| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
ins.attribs = synced(&blk, &defs, ins);
|
||||
}
|
||||
};
|
||||
let spaces: Vec<cadcraft_doc::Space> =
|
||||
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
|
||||
for sp in spaces {
|
||||
let Some(store) = d.space_mut(&sp) else { continue };
|
||||
let hs = references(store.iter(), &blk.name);
|
||||
for h in hs {
|
||||
n += usize::from(store.modify(h, update));
|
||||
}
|
||||
}
|
||||
for (bname, b) in d.blocks.iter_mut() {
|
||||
if bname.eq_ignore_ascii_case(&blk.name) {
|
||||
continue;
|
||||
}
|
||||
let hs = references(b.entities.iter(), &blk.name);
|
||||
if !hs.is_empty() {
|
||||
let b = Arc::make_mut(b);
|
||||
for h in hs {
|
||||
n += usize::from(b.entities.modify(h, update));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((blk.name.clone(), n))
|
||||
}
|
||||
|
||||
/// Whether block `name` has attribute definitions.
|
||||
fn has_attdefs(s: &Session, name: &str) -> bool {
|
||||
s.doc().ok().and_then(|d| d.block(name)).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::AttDef(a) if !a.constant)))
|
||||
}
|
||||
|
||||
fn run_attsync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attsync", "`name` (or `handle` of a block reference) is required"))?;
|
||||
match s.doc()?.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => i.block.clone(),
|
||||
_ => return Err(bad("attsync", "`handle` is not a block reference")),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (block, n) = sync(s, &name)?;
|
||||
Ok(json!({ "block": block, "references": n }))
|
||||
}
|
||||
|
||||
/// Names of the blocks with attribute definitions that match `spec` (comma-separated wildcards).
|
||||
fn blocks_with_attdefs(s: &Session, spec: &str) -> Vec<String> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
d.blocks
|
||||
.values()
|
||||
.filter(|b| !b.anonymous && spec.split(',').any(|p| wildcard(p.trim(), &b.name)) && has_attdefs(s, &b.name))
|
||||
.map(|b| b.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Option,
|
||||
List,
|
||||
Name,
|
||||
Select,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AttsyncM {
|
||||
stage: Stage,
|
||||
/// The stage to return to after `?`.
|
||||
back: Stage,
|
||||
sel: SelectPhase,
|
||||
/// The block picked by Select, waiting for confirmation.
|
||||
picked: String,
|
||||
}
|
||||
|
||||
impl AttsyncM {
|
||||
/// Sync `name` (when it has attributes) and end; otherwise say why and keep prompting.
|
||||
fn finish(&mut self, s: &mut Session, name: &str) -> Result<Step> {
|
||||
if s.doc()?.block(name).is_none() {
|
||||
return Err(EngineError::Other(format!("Block \"{name}\" not found.")));
|
||||
}
|
||||
if !has_attdefs(s, name) {
|
||||
return Err(EngineError::Other(format!("Block \"{name}\" has no attribute definitions.")));
|
||||
}
|
||||
let (block, n) = sync(s, name)?;
|
||||
s.echo(format!("ATTSYNC block {block}: {n} reference{} synchronized.", if n == 1 { "" } else { "s" }));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AttsyncM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTSYNC"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Option => Prompt::new("Enter an option", KW).kw(&["?", "Name", "Select"]).default("Select"),
|
||||
Stage::List => Prompt::new("Enter block(s) to list", Accept::TEXT).default("*"),
|
||||
Stage::Name => Prompt::new("Enter name of block to sync", Accept::TEXT).kw(&["?"]),
|
||||
Stage::Select => Prompt::new("Select a block", Accept::SELECT),
|
||||
Stage::Confirm => Prompt::new(format!("ATTSYNC block {}?", self.picked), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
Stage::Option => match &i {
|
||||
Input::Keyword(k) if k == "?" => {
|
||||
self.back = Stage::Option;
|
||||
self.stage = Stage::List;
|
||||
}
|
||||
Input::Keyword(k) if k == "Name" => self.stage = Stage::Name,
|
||||
Input::Keyword(_) | Input::Enter => {
|
||||
self.sel = SelectPhase::single();
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
Stage::List => {
|
||||
let spec = match &i {
|
||||
Input::Text(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
Input::Text(_) | Input::Enter => "*".to_string(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let names = blocks_with_attdefs(s, &spec);
|
||||
s.echo(if names.is_empty() {
|
||||
"No blocks with attributes match.".to_string()
|
||||
} else {
|
||||
format!("Blocks with attributes: {}", names.join(", "))
|
||||
});
|
||||
self.stage = self.back;
|
||||
}
|
||||
Stage::Name => match &i {
|
||||
Input::Keyword(k) if k == "?" => {
|
||||
self.back = Stage::Name;
|
||||
self.stage = Stage::List;
|
||||
}
|
||||
Input::Text(t) if !t.trim().is_empty() => return self.finish(s, t.trim()),
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
Stage::Select => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => return Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(ins)) => Some(ins.block.clone()),
|
||||
_ => None,
|
||||
});
|
||||
self.sel = SelectPhase::single();
|
||||
match block {
|
||||
Some(b) if has_attdefs(s, &b) => {
|
||||
self.picked = b;
|
||||
self.stage = Stage::Confirm;
|
||||
}
|
||||
Some(b) => return Err(EngineError::Other(format!("Block \"{b}\" has no attribute definitions."))),
|
||||
None => return Err(EngineError::Other("That object is not a block reference.".into())),
|
||||
}
|
||||
}
|
||||
},
|
||||
Stage::Confirm => match &i {
|
||||
Input::Keyword(k) if k == "No" => return Ok(Step::Done),
|
||||
Input::Keyword(_) | Input::Enter => {
|
||||
let name = self.picked.clone();
|
||||
return self.finish(s, &name);
|
||||
}
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attdisp_keywords_map_to_attmode() {
|
||||
assert_eq!([attmode("Normal"), attmode("on"), attmode("OFF"), attmode("x")], [Some(1), Some(2), Some(0), None]);
|
||||
assert_eq!([attdisp_keyword(0), attdisp_keyword(1), attdisp_keyword(2)], ["OFF", "Normal", "ON"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! INSERT of a drawing file (`-INSERT C:\parts\bolt.dxf`, `bolt=C:\parts\bolt.dwg`, JSON `file`)
|
||||
//! and exploded insertion (`*name`).
|
||||
//!
|
||||
//! A file becomes a block named after it (or after the part before `=`): its model space, with
|
||||
//! the file's `$INSBASE` as the base point, plus the layers, linetypes, styles and blocks it needs
|
||||
//! (names this drawing already has keep this drawing's definitions). The file is read through the
|
||||
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::file::io;
|
||||
use super::super::modify::explode_kind;
|
||||
use super::{references_block, valid_block_name};
|
||||
use crate::cmd::{bad, str_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// A drawing file named at the block name prompt.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct FileRef {
|
||||
/// The block to define.
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
/// `name=path`: redefine the block without asking.
|
||||
explicit: bool,
|
||||
}
|
||||
|
||||
impl FileRef {
|
||||
/// Whether to ask before redefining: a plain path whose block already exists.
|
||||
pub fn ask_redefine(&self, s: &Session) -> bool {
|
||||
!self.explicit && s.doc().is_ok_and(|d| d.block(&self.name).is_some())
|
||||
}
|
||||
|
||||
/// Read the file and (re)define its block. Returns the block's name.
|
||||
pub fn define(&self, s: &mut Session) -> Result<String> {
|
||||
let src = read_drawing(&self.path)?;
|
||||
define_from(s, &src, &self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file `t` names at the block name prompt: `name=path` (`name=` alone: the file `name`), or a
|
||||
/// path (it has a folder separator or a .dwg/.dxf extension) that isn't the name of a block.
|
||||
pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
|
||||
let t = t.trim();
|
||||
if let Some((name, path)) = t.split_once('=') {
|
||||
let name = name.trim();
|
||||
let path = if path.trim().is_empty() { name } else { path.trim() };
|
||||
return (!name.is_empty()).then(|| FileRef { name: name.to_string(), path: path.to_string(), explicit: true });
|
||||
}
|
||||
let lower = t.to_ascii_lowercase();
|
||||
let pathlike = t.contains(['/', '\\']) || lower.ends_with(".dwg") || lower.ends_with(".dxf");
|
||||
if !pathlike || s.doc().is_ok_and(|d| d.block(t).is_some()) {
|
||||
return None;
|
||||
}
|
||||
Some(FileRef { name: stem(t), path: t.to_string(), explicit: false })
|
||||
}
|
||||
|
||||
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
|
||||
fn stem(path: &str) -> String {
|
||||
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
match file.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
|
||||
_ => file.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a drawing file through the io hooks; a path without extension tries .dwg, then .dxf.
|
||||
fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
let hooks = io().ok_or_else(|| bad("insert", "file formats are not available in this build"))?;
|
||||
let has_ext = stem(path) != path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
let candidates = if has_ext { vec![path.to_string()] } else { vec![path.to_string(), format!("{path}.dwg"), format!("{path}.dxf")] };
|
||||
let mut last = None;
|
||||
for p in candidates {
|
||||
match read_file(&p) {
|
||||
Ok(bytes) => return (hooks.read)(&bytes, &p).map_err(|e| bad("insert", format!("{p}: {e}"))),
|
||||
Err(e) => last = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| bad("insert", format!("{path}: file not found"))))
|
||||
}
|
||||
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
}
|
||||
|
||||
/// (Re)define block `name` from `src`'s model space with base point `$INSBASE`, adding the
|
||||
/// layers, linetypes, styles and blocks of `src` this drawing doesn't have. Nothing changes when
|
||||
/// the definition fails (an invalid name, or a block that would contain itself).
|
||||
fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
if !valid_block_name(name) {
|
||||
return Err(bad("insert", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
let mut d = s.doc()?.clone();
|
||||
// Redefining keeps the existing definition's name (block names are case-insensitive).
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
merge_tables(&mut d, src);
|
||||
let renames = block_names(&d, src, &name);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
nb.name = to.clone();
|
||||
nb.entities = Default::default();
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
nb.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(to.clone(), Arc::new(nb));
|
||||
}
|
||||
let mut b = Block::new(&name);
|
||||
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
|
||||
let mut ents = Vec::new();
|
||||
for e in src.model.iter() {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
d.ensure_layer(&e.common.layer);
|
||||
ents.push(e);
|
||||
}
|
||||
if references_block(&d, &ents, &name) {
|
||||
return Err(bad("insert", format!("Block \"{name}\" references itself.")));
|
||||
}
|
||||
for e in ents {
|
||||
b.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(name.clone(), Arc::new(b));
|
||||
*s.doc_mut()? = d;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition).
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
|
||||
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
|
||||
merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
||||
/// already has (or the block being defined) keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.insert(defining.to_ascii_uppercase());
|
||||
for (name, b) in &src.blocks {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
||||
/// links to other source objects (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
let mut e = e.clone();
|
||||
e.handle = d.new_handle();
|
||||
match &mut e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
if let Some(n) = renames.get(&i.block) {
|
||||
i.block = n.clone();
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
dm.assoc.clear();
|
||||
if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {
|
||||
dm.block = Some(n.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Replace block reference `h` by the objects of its block (an exploded insertion).
|
||||
pub(super) fn explode_insert(s: &mut Session, h: Handle) -> Result<Vec<Handle>> {
|
||||
let space = s.space();
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let parts = explode_kind(s.doc()?, &e).unwrap_or_default();
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(h);
|
||||
let mut out = Vec::new();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The JSON INSERT parameters with `file` defined as a block and `*name` as `explode: true`.
|
||||
pub(super) fn json_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut p = p.clone();
|
||||
let mut name = str_param(&p, "name").map(str::trim).map(str::to_string);
|
||||
if let Some(n) = name.as_deref().and_then(|n| n.strip_prefix('*')) {
|
||||
name = Some(n.trim().to_string());
|
||||
p["explode"] = json!(true);
|
||||
}
|
||||
if let Some(path) = str_param(&p, "file").map(str::to_string) {
|
||||
let n = name.clone().filter(|n| !n.is_empty()).unwrap_or_else(|| stem(&path));
|
||||
name = Some(FileRef { name: n, path, explicit: true }.define(s)?);
|
||||
}
|
||||
if let Some(n) = name {
|
||||
p["name"] = json!(n);
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_names_become_block_names() {
|
||||
assert_eq!(stem("C:\\parts\\bolt.dxf"), "bolt");
|
||||
assert_eq!(stem("/parts/nut.v2.DWG"), "nut.v2");
|
||||
assert_eq!(stem("washer"), "washer");
|
||||
let s = Session::new();
|
||||
let f = file_ref(&s, "M8=parts/bolt.dwg").unwrap();
|
||||
assert_eq!((f.name.as_str(), f.path.as_str(), f.explicit), ("M8", "parts/bolt.dwg", true));
|
||||
let f = file_ref(&s, "bolt=").unwrap();
|
||||
assert_eq!((f.name.as_str(), f.path.as_str()), ("bolt", "bolt"));
|
||||
assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into()));
|
||||
assert!(file_ref(&s, "BOLT").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, groups) and
|
||||
//! zero-length geometry or empty text.
|
||||
//!
|
||||
//! The JSON form takes `{type?, names?}` and purges without asking. Typed, it asks for the type,
|
||||
//! the names and whether to verify each one, as AutoCAD's -PURGE does.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::super::qselect::wildcard;
|
||||
use super::super::{bad, find_command, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Blocks,
|
||||
Dimstyles,
|
||||
Groups,
|
||||
Layers,
|
||||
Linetypes,
|
||||
MleaderStyles,
|
||||
TableStyles,
|
||||
TextStyles,
|
||||
ZeroLength,
|
||||
EmptyText,
|
||||
/// An object type CADCraft does not have: nothing to purge.
|
||||
Absent,
|
||||
All,
|
||||
}
|
||||
|
||||
/// Prompt keyword, JSON names (normalised: lower case, letters and digits only), kind.
|
||||
const KINDS: &[(&str, &[&str], Kind)] = &[
|
||||
("Blocks", &["blocks", "block"], Kind::Blocks),
|
||||
("DEtailviewstyles", &["detailviewstyles"], Kind::Absent),
|
||||
("Dimstyles", &["dimstyles", "dimstyle"], Kind::Dimstyles),
|
||||
("Groups", &["groups", "group"], Kind::Groups),
|
||||
("LAyers", &["layers", "layer"], Kind::Layers),
|
||||
("LTypes", &["ltypes", "linetypes", "linetype"], Kind::Linetypes),
|
||||
("MAterials", &["materials"], Kind::Absent),
|
||||
("MUltileaderstyles", &["multileaderstyles", "mleaderstyles", "mleaderstyle"], Kind::MleaderStyles),
|
||||
("Plotstyles", &["plotstyles"], Kind::Absent),
|
||||
("SHapes", &["shapes"], Kind::Absent),
|
||||
("textSTyles", &["textstyles", "textstyle", "styles", "style"], Kind::TextStyles),
|
||||
("Mlinestyles", &["mlinestyles"], Kind::Absent),
|
||||
("SEctionviewstyles", &["sectionviewstyles"], Kind::Absent),
|
||||
("Tablestyles", &["tablestyles", "tablestyle"], Kind::TableStyles),
|
||||
("Visualstyles", &["visualstyles"], Kind::Absent),
|
||||
("Regapps", &["regapps"], Kind::Absent),
|
||||
("Zero-length geometry", &["zerolengthgeometry", "zerolength"], Kind::ZeroLength),
|
||||
("Empty text objects", &["emptytextobjects", "emptytext"], Kind::EmptyText),
|
||||
("Orphaned data", &["orphaneddata", "orphaned"], Kind::Absent),
|
||||
("All", &["all"], Kind::All),
|
||||
];
|
||||
|
||||
/// The named kinds `All` purges, in an order where purging one frees the next (blocks free
|
||||
/// layers, layers free linetypes, dimension styles free text styles).
|
||||
const ALL: &[Kind] =
|
||||
&[Kind::Blocks, Kind::Groups, Kind::Dimstyles, Kind::MleaderStyles, Kind::TableStyles, Kind::Layers, Kind::Linetypes, Kind::TextStyles];
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn kind_of(name: &str) -> Option<(&'static str, Kind)> {
|
||||
let n = norm(name);
|
||||
KINDS.iter().find(|(k, alias, _)| norm(k) == n || alias.contains(&n.as_str())).map(|(k, _, kind)| (*k, *kind))
|
||||
}
|
||||
|
||||
/// Singular noun for messages.
|
||||
fn noun(kind: Kind) -> &'static str {
|
||||
match kind {
|
||||
Kind::Blocks => "block",
|
||||
Kind::Dimstyles => "dimension style",
|
||||
Kind::Groups => "group",
|
||||
Kind::Layers => "layer",
|
||||
Kind::Linetypes => "linetype",
|
||||
Kind::MleaderStyles => "multileader style",
|
||||
Kind::TableStyles => "table style",
|
||||
Kind::TextStyles => "text style",
|
||||
Kind::ZeroLength => "zero-length object",
|
||||
Kind::EmptyText => "empty text object",
|
||||
Kind::Absent | Kind::All => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn json_key(kind: Kind) -> &'static str {
|
||||
match kind {
|
||||
Kind::Blocks => "blocks",
|
||||
Kind::Dimstyles => "dimstyles",
|
||||
Kind::Groups => "groups",
|
||||
Kind::Layers => "layers",
|
||||
Kind::Linetypes => "linetypes",
|
||||
Kind::MleaderStyles => "mleaderstyles",
|
||||
Kind::TableStyles => "tablestyles",
|
||||
Kind::TextStyles => "textstyles",
|
||||
Kind::ZeroLength => "zeroLength",
|
||||
Kind::EmptyText => "emptyText",
|
||||
Kind::Absent | Kind::All => "other",
|
||||
}
|
||||
}
|
||||
|
||||
fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::Entity>> {
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000)
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
|
||||
fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
let mut used = HashSet::new();
|
||||
let mut frontier: Vec<String> = Vec::new();
|
||||
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut guard = 0;
|
||||
while let Some(n) = frontier.pop() {
|
||||
guard += 1;
|
||||
if guard > 100_000 || !used.insert(n.to_ascii_uppercase()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = d.block(&n) {
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
fn is(name: &str, list: &[&str]) -> bool {
|
||||
list.iter().any(|n| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Names of the unused objects of a named kind, in table order.
|
||||
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
|
||||
match kind {
|
||||
Kind::Blocks => {
|
||||
let used = used_blocks(d);
|
||||
d.blocks.values().filter(|b| !used.contains(&b.name.to_ascii_uppercase())).map(|b| b.name.clone()).collect()
|
||||
}
|
||||
Kind::Layers => {
|
||||
let used = super::super::layer::used_layers(d);
|
||||
let cur = d.header.str("CLAYER", "0");
|
||||
d.layers
|
||||
.iter()
|
||||
.filter(|l| !is(&l.name, &["0", "Defpoints", &cur]) && !used.contains(&l.name.to_ascii_lowercase()))
|
||||
.map(|l| l.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::Linetypes => {
|
||||
let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone())));
|
||||
let cur = d.header.str("CELTYPE", "ByLayer");
|
||||
d.linetypes
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &["ByBlock", "ByLayer", "Continuous", &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::TextStyles => {
|
||||
let used = lower(&mut super::super::props::text_styles_in_use(d).into_iter());
|
||||
let cur = d.header.str("TEXTSTYLE", "Standard");
|
||||
d.text_styles
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::Dimstyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.style.clone()) } else { None }));
|
||||
let cur = d.header.str("DIMSTYLE", "Standard");
|
||||
d.dim_styles
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::MleaderStyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::MLeader(m) = &e.kind { Some(m.style.clone()) } else { None }));
|
||||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
||||
d.mleader_styles
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::TableStyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.style.clone()) } else { None }));
|
||||
let cur = d.header.str("CTABLESTYLE", "Standard");
|
||||
d.table_styles
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
// Groups whose members are all gone.
|
||||
Kind::Groups => d.groups.iter().filter(|g| !g.members.iter().any(|h| d.entity(*h).is_some())).map(|g| g.name.clone()).collect(),
|
||||
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn matching(d: &Drawing, kind: Kind, names: &str) -> Vec<String> {
|
||||
let pats: Vec<&str> = names.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect();
|
||||
candidates(d, kind).into_iter().filter(|n| pats.iter().any(|p| wildcard(p, n))).collect()
|
||||
}
|
||||
|
||||
/// Run a command body directly: the purge records a single undo step.
|
||||
fn call(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.run)(s, p)
|
||||
}
|
||||
|
||||
/// Purge one named object. `false` when it is in use after all (or gone).
|
||||
fn purge_one(s: &mut Session, kind: Kind, name: &str) -> Result<bool> {
|
||||
let d = s.doc_mut()?;
|
||||
let n = name.to_string();
|
||||
let gone = match kind {
|
||||
Kind::Layers => return Ok(call(s, "layer.delete", &json!({ "name": n })).is_ok()),
|
||||
Kind::TextStyles => return Ok(call(s, "style.delete", &json!({ "name": n })).is_ok()),
|
||||
Kind::Blocks => {
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned();
|
||||
key.is_some_and(|k| d.blocks.remove(&k).is_some())
|
||||
}
|
||||
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, name),
|
||||
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, name),
|
||||
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, name),
|
||||
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, name),
|
||||
Kind::Groups => retain(&mut d.groups, |g| &g.name, name),
|
||||
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
|
||||
};
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
fn retain<T>(v: &mut Vec<T>, name: impl Fn(&T) -> &String, n: &str) -> bool {
|
||||
let before = v.len();
|
||||
v.retain(|t| !name(t).eq_ignore_ascii_case(n));
|
||||
v.len() != before
|
||||
}
|
||||
|
||||
/// Model-space and layout objects with no length, or text with no characters.
|
||||
fn degenerate(d: &Drawing, kind: Kind) -> Vec<Handle> {
|
||||
let zero = |e: &EntityKind| match e {
|
||||
EntityKind::Line(l) => l.a == l.b,
|
||||
EntityKind::Circle(c) => c.radius == 0.0,
|
||||
EntityKind::Arc(a) => a.radius == 0.0,
|
||||
EntityKind::LwPolyline(p) => p.vertices.first().is_some_and(|f| p.vertices.iter().all(|v| v.p == f.p)),
|
||||
EntityKind::Polyline3d(p) => p.points.first().is_some_and(|f| p.points.iter().all(|v| v == f)),
|
||||
_ => false,
|
||||
};
|
||||
let empty = |e: &EntityKind| match e {
|
||||
EntityKind::Text(t) => t.value.trim().is_empty(),
|
||||
EntityKind::MText(t) => t.contents.trim().is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
d.model
|
||||
.iter()
|
||||
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
|
||||
.filter(|e| if kind == Kind::ZeroLength { zero(&e.kind) } else { empty(&e.kind) })
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Purges and the messages it produced, collected for the JSON result.
|
||||
#[derive(Default)]
|
||||
struct Report {
|
||||
purged: Map<String, Value>,
|
||||
lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn add(&mut self, kind: Kind, name: &str) {
|
||||
self.lines.push(format!("Deleting {} \"{name}\".", noun(kind)));
|
||||
if let Value::Array(a) = self.purged.entry(json_key(kind)).or_insert_with(|| Value::Array(Vec::new())) {
|
||||
a.push(json!(name));
|
||||
}
|
||||
}
|
||||
fn count(&self, kind: Kind) -> usize {
|
||||
self.purged.get(json_key(kind)).and_then(Value::as_array).map_or(0, Vec::len)
|
||||
}
|
||||
fn summary(&mut self, kind: Kind) {
|
||||
let n = self.count(kind);
|
||||
let noun = noun(kind);
|
||||
self.lines.push(match n {
|
||||
0 => format!("No unreferenced {noun}s found."),
|
||||
1 => format!("1 {noun} deleted."),
|
||||
n => format!("{n} {noun}s deleted."),
|
||||
});
|
||||
}
|
||||
fn json(self) -> Value {
|
||||
let msg = self.lines.join("\n");
|
||||
let count = |k: &str| self.purged.get(k).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
json!({
|
||||
"blocks": count("blocks"), "layers": count("layers"), "linetypes": count("linetypes"),
|
||||
"purged": Value::Object(self.purged.clone()), "message": msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Purge everything matching `names` for one kind (zero-length and empty text ignore names).
|
||||
fn purge_kind(s: &mut Session, kind: Kind, names: &str, r: &mut Report) -> Result<()> {
|
||||
match kind {
|
||||
Kind::All => {
|
||||
for k in ALL {
|
||||
purge_kind(s, *k, names, r)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Kind::Absent => {
|
||||
r.lines.push("No unreferenced objects of this type found.".into());
|
||||
return Ok(());
|
||||
}
|
||||
Kind::ZeroLength | Kind::EmptyText => {
|
||||
let d = s.doc_mut()?;
|
||||
for h in degenerate(d, kind) {
|
||||
if d.remove_entity(h).is_some() {
|
||||
r.add(kind, &h.hex());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for n in matching(s.doc()?, kind, names) {
|
||||
if purge_one(s, kind, &n)? {
|
||||
r.add(kind, &n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.summary(kind);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ty = str_param(p, "type").unwrap_or("all");
|
||||
let (_, kind) = kind_of(ty).ok_or_else(|| bad("purge", format!("unknown type `{ty}`")))?;
|
||||
let names = str_param(p, "names").unwrap_or("*");
|
||||
let mut r = Report::default();
|
||||
purge_kind(s, kind, names, &mut r)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(r.json())
|
||||
}
|
||||
|
||||
// ---------------- -PURGE prompts ----------------
|
||||
|
||||
/// Keyword-only: spaces separate inputs.
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
/// A name list: Space ends the input.
|
||||
const NAME: Accept = Accept { number: true, ..Accept::TEXT };
|
||||
|
||||
#[derive(Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Type,
|
||||
Names(Kind),
|
||||
Verify(Kind, String),
|
||||
/// Confirm each name: the kind being asked about, its remaining names, the kinds still to
|
||||
/// come (for All) and the name pattern.
|
||||
Confirm {
|
||||
kind: Kind,
|
||||
queue: VecDeque<String>,
|
||||
rest: VecDeque<Kind>,
|
||||
names: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::cmd) struct PurgeM {
|
||||
stage: Stage,
|
||||
report: Report,
|
||||
}
|
||||
|
||||
impl PurgeM {
|
||||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||||
for l in std::mem::take(&mut self.report.lines) {
|
||||
s.echo(l);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// Move to the next kind with candidates, echoing a summary for each finished one.
|
||||
fn next_confirm(&mut self, s: &mut Session, mut rest: VecDeque<Kind>, names: String) -> Result<Step> {
|
||||
while let Some(kind) = rest.pop_front() {
|
||||
let queue: VecDeque<String> = matching(s.doc()?, kind, &names).into();
|
||||
if queue.is_empty() {
|
||||
self.report.summary(kind);
|
||||
continue;
|
||||
}
|
||||
self.stage = Stage::Confirm { kind, queue, rest, names };
|
||||
for l in std::mem::take(&mut self.report.lines) {
|
||||
s.echo(l);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.finish(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PurgeM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-PURGE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match &self.stage {
|
||||
Stage::Type => {
|
||||
let kws: Vec<&str> = KINDS.iter().map(|(k, _, _)| *k).collect();
|
||||
Prompt::new("Enter type of unused objects to purge", KW).kw(&kws)
|
||||
}
|
||||
Stage::Names(_) => Prompt::new("Enter name(s) to purge", NAME).default("*"),
|
||||
Stage::Verify(..) => Prompt::new("Verify each name to be purged?", KW).kw(&["Yes", "No"]).default("Y"),
|
||||
Stage::Confirm { kind, queue, .. } => {
|
||||
let n = queue.front().map(String::as_str).unwrap_or("");
|
||||
Prompt::new(format!("Purge {} \"{n}\"?", noun(*kind)), KW).kw(&["Yes", "No"]).default("N")
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if i == Input::Cancel {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
match std::mem::take(&mut self.stage) {
|
||||
Stage::Type => {
|
||||
let Input::Keyword(k) = &i else {
|
||||
if i == Input::Enter {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
return Err(EngineError::Other("Invalid option keyword.".into()));
|
||||
};
|
||||
let Some((_, kind)) = kind_of(k) else { return Err(EngineError::Other("Invalid option keyword.".into())) };
|
||||
// Zero-length geometry, empty text and orphaned data take no names.
|
||||
match kind {
|
||||
Kind::ZeroLength | Kind::EmptyText => {
|
||||
purge_kind(s, kind, "*", &mut self.report)?;
|
||||
self.finish(s)
|
||||
}
|
||||
Kind::Absent if k == "Orphaned data" => {
|
||||
purge_kind(s, kind, "*", &mut self.report)?;
|
||||
self.finish(s)
|
||||
}
|
||||
_ => {
|
||||
self.stage = Stage::Names(kind);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
Stage::Names(kind) => {
|
||||
let names = match &i {
|
||||
Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
_ => "*".into(),
|
||||
};
|
||||
self.stage = Stage::Verify(kind, names);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Verify(kind, names) => match &i {
|
||||
Input::Keyword(k) if k == "No" => {
|
||||
purge_kind(s, kind, &names, &mut self.report)?;
|
||||
self.finish(s)
|
||||
}
|
||||
Input::Keyword(_) | Input::Enter => {
|
||||
let rest: VecDeque<Kind> = if kind == Kind::All { ALL.iter().copied().collect() } else { VecDeque::from([kind]) };
|
||||
self.next_confirm(s, rest, names)
|
||||
}
|
||||
_ => {
|
||||
self.stage = Stage::Verify(kind, names);
|
||||
Err(EngineError::Other("Enter Yes or No.".into()))
|
||||
}
|
||||
},
|
||||
Stage::Confirm { kind, mut queue, rest, names } => {
|
||||
let yes = matches!(&i, Input::Keyword(k) if k == "Yes");
|
||||
if !yes && !matches!(&i, Input::Keyword(_) | Input::Enter) {
|
||||
self.stage = Stage::Confirm { kind, queue, rest, names };
|
||||
return Err(EngineError::Other("Enter Yes or No.".into()));
|
||||
}
|
||||
if let Some(n) = queue.pop_front()
|
||||
&& yes
|
||||
&& purge_one(s, kind, &n)?
|
||||
{
|
||||
self.report.add(kind, &n);
|
||||
if let Some(l) = self.report.lines.pop() {
|
||||
s.echo(l);
|
||||
}
|
||||
}
|
||||
if queue.is_empty() {
|
||||
self.report.summary(kind);
|
||||
return self.next_confirm(s, rest, names);
|
||||
}
|
||||
self.stage = Stage::Confirm { kind, queue, rest, names };
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! The clipboard every open drawing shares (COPYCLIP, CUTCLIP, COPYBASE; PASTECLIP, PASTEORIG,
|
||||
//! PASTEBLOCK).
|
||||
//!
|
||||
//! Like AutoCAD's, it holds the copied objects together with what they refer to in their drawing:
|
||||
//! layers, linetypes, text, dimension, multileader and table styles, and the blocks they reference
|
||||
//! (nested ones too). Pasting adds the ones the target drawing doesn't have; a name the target
|
||||
//! already has keeps the target's definition, and a generated (anonymous `*…`) block whose name is
|
||||
//! taken gets a fresh one. Pasted objects get new handles.
|
||||
//!
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
|
||||
use super::{bad, has_doc};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// More objects than this aren't mirrored to (or read from) the system clipboard.
|
||||
const MAX_SYSTEM_OBJECTS: usize = 50_000;
|
||||
/// Bounds the walk through nested block references.
|
||||
const MAX_WALK: usize = 5_000_000;
|
||||
|
||||
/// Where the clipboard's objects came from, and the definitions they need.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClipSource {
|
||||
/// `DocState::uid` of the drawing they were copied from; `None` when they came from the system
|
||||
/// clipboard.
|
||||
pub uid: Option<u64>,
|
||||
/// The layers, linetypes, styles and blocks the objects reference (and the source's header).
|
||||
pub defs: Arc<Drawing>,
|
||||
/// Counts copies, so a front end mirrors each new clipboard to the system clipboard once.
|
||||
pub serial: u64,
|
||||
}
|
||||
|
||||
/// Put copies of `ents` from the active drawing on the clipboard, with `base` as base point.
|
||||
pub(crate) fn copy(s: &mut Session, ents: Vec<Entity>, base: Vec2) -> Result<()> {
|
||||
let uid = s.state()?.uid;
|
||||
let defs = defs_for(s.doc()?, &ents);
|
||||
set(s, ents, base, defs, Some(uid));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, ents: Vec<Entity>, base: Vec2, defs: Drawing, uid: Option<u64>) {
|
||||
let serial = s.clipboard_source.as_ref().map_or(0, |c| c.serial).wrapping_add(1);
|
||||
s.clipboard = ents;
|
||||
s.clipboard_base = base;
|
||||
s.clipboard_source = Some(ClipSource { uid, defs: Arc::new(defs), serial });
|
||||
}
|
||||
|
||||
/// Enabled check of PASTECLIP and PASTEBLOCK: something to paste.
|
||||
pub(crate) fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Enabled check of PASTEORIG: as in AutoCAD, it pastes objects copied from another drawing.
|
||||
pub(crate) fn can_paste_orig(s: &Session) -> std::result::Result<(), String> {
|
||||
has_clip(s)?;
|
||||
if from_active(s) {
|
||||
return Err("the clipboard holds objects from this drawing; Paste to Original Coordinates pastes into another drawing".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the clipboard was copied from the active drawing.
|
||||
pub fn from_active(s: &Session) -> bool {
|
||||
let active = s.state().ok().map(|st| st.uid);
|
||||
s.clipboard_source.as_ref().is_some_and(|c| c.uid.is_some() && c.uid == active)
|
||||
}
|
||||
|
||||
/// Paste the clipboard into the active drawing's current space, moved by `m`; selects and
|
||||
/// returns the new objects.
|
||||
pub(crate) fn paste(s: &mut Session, m: &Mat3) -> Result<Vec<Handle>> {
|
||||
let ents = s.clipboard.clone();
|
||||
let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone());
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let renames = defs.map(|x| merge(d, &x)).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for e in &ents {
|
||||
let mut e = adopt(d, e, &renames);
|
||||
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)
|
||||
}
|
||||
|
||||
/// PASTEBLOCK: the clipboard becomes a new block (named `A$C…`, base point = the clipboard's), and
|
||||
/// a reference to it is inserted at `at`. Returns the block reference.
|
||||
pub(crate) fn paste_block(s: &mut Session, at: Vec2) -> Result<Handle> {
|
||||
let ents = s.clipboard.clone();
|
||||
if ents.is_empty() {
|
||||
return Err(bad("pasteblock", "the clipboard is empty"));
|
||||
}
|
||||
let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone());
|
||||
let base = s.clipboard_base;
|
||||
let d = s.doc_mut()?;
|
||||
let renames = defs.map(|x| merge(d, &x)).unwrap_or_default();
|
||||
let seed = d.handseed;
|
||||
let name = (0..1_000_000u64)
|
||||
.map(|i| format!("A$C{:08X}", seed.wrapping_add(i) & 0xFFFF_FFFF))
|
||||
.find(|n| d.block(n).is_none())
|
||||
.ok_or_else(|| bad("pasteblock", "no free block name"))?;
|
||||
let mut b = Block::new(&name);
|
||||
b.base = base.to3(0.0);
|
||||
for e in &ents {
|
||||
let e = adopt(d, e, &renames);
|
||||
d.ensure_layer(&e.common.layer);
|
||||
b.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(name.clone(), Arc::new(b));
|
||||
let h = super::blocks::insert(s, &name, at, 1.0, 0.0, &serde_json::Map::new())?;
|
||||
s.set_selection(vec![h]);
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The clipboard as DXF text (base point in `$INSBASE`), for the system clipboard. `None` when
|
||||
/// it is empty, very large, or this build has no DXF writer.
|
||||
pub fn system_text(s: &Session) -> Option<String> {
|
||||
if s.clipboard.is_empty() || s.clipboard.len() > MAX_SYSTEM_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let hooks = super::file::io()?;
|
||||
let mut d = Drawing::new_imperial();
|
||||
let renames = match &s.clipboard_source {
|
||||
Some(c) => {
|
||||
d.header = c.defs.header.clone();
|
||||
merge(&mut d, &c.defs)
|
||||
}
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
d.header.set("INSBASE", HVal::Point(s.clipboard_base.to3(0.0)));
|
||||
for e in &s.clipboard {
|
||||
let e = adopt(&mut d, e, &renames);
|
||||
d.ensure_layer(&e.common.layer);
|
||||
d.model.push(e);
|
||||
}
|
||||
let bytes = (hooks.write)(&d, "clipboard.dxf").ok()?;
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
/// Make DXF text (from the system clipboard) the clipboard: its model space objects with what
|
||||
/// they reference, the base point from `$INSBASE`. `false` (clipboard unchanged) when the text
|
||||
/// isn't a DXF drawing with objects.
|
||||
pub fn load_system_text(s: &mut Session, text: &str) -> bool {
|
||||
let mut lines = text.lines().map(str::trim);
|
||||
if lines.next() != Some("0") || lines.next() != Some("SECTION") || text.len() > 256 << 20 {
|
||||
return false;
|
||||
}
|
||||
let Some(hooks) = super::file::io() else { return false };
|
||||
let Ok(src) = (hooks.read)(text.as_bytes(), "clipboard.dxf") else { return false };
|
||||
if src.model.is_empty() || src.model.len() > MAX_SYSTEM_OBJECTS {
|
||||
return false;
|
||||
}
|
||||
let ents: Vec<Entity> = src.model.iter().map(|e| (**e).clone()).collect();
|
||||
let base = src.header.point("INSBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO);
|
||||
let defs = defs_for(&src, &ents);
|
||||
set(s, ents, base, defs, None);
|
||||
true
|
||||
}
|
||||
|
||||
/// Upper-case names of the table records and blocks some objects use.
|
||||
#[derive(Default)]
|
||||
struct Used {
|
||||
layers: HashSet<String>,
|
||||
linetypes: HashSet<String>,
|
||||
text_styles: HashSet<String>,
|
||||
dim_styles: HashSet<String>,
|
||||
mleader_styles: HashSet<String>,
|
||||
table_styles: HashSet<String>,
|
||||
blocks: HashSet<String>,
|
||||
}
|
||||
|
||||
fn up(n: &str) -> String {
|
||||
n.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
impl Used {
|
||||
/// Note what `e` uses; returns the blocks it references.
|
||||
fn note(&mut self, e: &Entity) -> Vec<String> {
|
||||
self.layers.insert(up(&e.common.layer));
|
||||
self.linetypes.insert(up(&e.common.linetype));
|
||||
let mut blocks = Vec::new();
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) => {
|
||||
self.text_styles.insert(up(&t.style));
|
||||
}
|
||||
EntityKind::MText(t) => {
|
||||
self.text_styles.insert(up(&t.style));
|
||||
}
|
||||
EntityKind::AttDef(a) => {
|
||||
self.text_styles.insert(up(&a.text.style));
|
||||
}
|
||||
EntityKind::Insert(i) => {
|
||||
self.text_styles.extend(i.attribs.iter().map(|a| up(&a.text.style)));
|
||||
blocks.push(i.block.clone());
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
self.dim_styles.insert(up(&dm.style));
|
||||
blocks.extend(dm.block.clone());
|
||||
}
|
||||
EntityKind::Leader(l) => {
|
||||
self.dim_styles.insert(up(&l.style));
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
self.mleader_styles.insert(up(&m.style));
|
||||
self.text_styles.extend(m.text.iter().map(|t| up(&t.style)));
|
||||
blocks.extend(m.block.as_ref().map(|b| b.block.clone()));
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
self.table_styles.insert(up(&t.style));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
blocks
|
||||
}
|
||||
}
|
||||
|
||||
/// The definitions `ents` need from `src`: the layers, linetypes, styles and blocks they (and the
|
||||
/// blocks they reference, nested ones too) use, and `src`'s header.
|
||||
fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
let mut used = Used::default();
|
||||
let mut frontier: Vec<String> = ents.iter().flat_map(|e| used.note(e)).collect();
|
||||
let mut walked = 0usize;
|
||||
while let Some(name) = frontier.pop() {
|
||||
if !used.blocks.insert(up(&name)) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = src.block(&name) else { continue };
|
||||
for e in b.entities.iter() {
|
||||
walked += 1;
|
||||
if walked > MAX_WALK {
|
||||
break;
|
||||
}
|
||||
frontier.extend(used.note(e));
|
||||
}
|
||||
}
|
||||
// Records referenced by other records.
|
||||
for l in src.layers.iter().filter(|l| used.layers.contains(&up(&l.name))) {
|
||||
used.linetypes.insert(up(&l.linetype));
|
||||
}
|
||||
for t in src.linetypes.iter().filter(|t| used.linetypes.contains(&up(&t.name))) {
|
||||
used.text_styles.extend(t.pattern.iter().filter_map(|p| p.style.as_deref()).map(up));
|
||||
}
|
||||
for ds in src.dim_styles.iter().filter(|t| used.dim_styles.contains(&up(&t.name))) {
|
||||
used.text_styles.insert(up(&ds.text_style));
|
||||
}
|
||||
for ms in src.mleader_styles.iter().filter(|t| used.mleader_styles.contains(&up(&t.name))) {
|
||||
used.text_styles.insert(up(&ms.text_style));
|
||||
}
|
||||
fn pick<T: Clone>(all: &[T], used: &HashSet<String>, name: fn(&T) -> &str) -> Vec<T> {
|
||||
all.iter().filter(|t| used.contains(&up(name(t)))).cloned().collect()
|
||||
}
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.header = src.header.clone();
|
||||
d.layers = pick(&src.layers, &used.layers, |l| &l.name);
|
||||
d.linetypes = pick(&src.linetypes, &used.linetypes, |l| &l.name);
|
||||
d.text_styles = pick(&src.text_styles, &used.text_styles, |t| &t.name);
|
||||
d.dim_styles = pick(&src.dim_styles, &used.dim_styles, |t| &t.name);
|
||||
d.mleader_styles = pick(&src.mleader_styles, &used.mleader_styles, |t| &t.name);
|
||||
d.table_styles = pick(&src.table_styles, &used.table_styles, |t| &t.name);
|
||||
d.blocks = src.blocks.iter().filter(|(k, _)| used.blocks.contains(&up(k))).map(|(k, b)| (k.clone(), b.clone())).collect();
|
||||
d.layouts.clear();
|
||||
d
|
||||
}
|
||||
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = block_names(d, defs);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
nb.name = to.clone();
|
||||
nb.entities = Default::default();
|
||||
for e in b.entities.iter() {
|
||||
let e = adopt(d, e, &renames);
|
||||
d.ensure_layer(&e.common.layer);
|
||||
nb.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(to.clone(), Arc::new(nb));
|
||||
}
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
let mut e = e.clone();
|
||||
e.handle = d.new_handle();
|
||||
match &mut e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
if let Some(n) = renames.get(&i.block) {
|
||||
i.block = n.clone();
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
dm.assoc.clear();
|
||||
if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {
|
||||
dm.block = Some(n.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
e
|
||||
}
|
||||
@@ -582,6 +582,7 @@ fn insert_knot(sp: &Spline, t: f64) -> Option<Spline> {
|
||||
weights: if rational { ctrl.iter().map(|(_, w)| *w).collect() } else { Vec::new() },
|
||||
fit: Vec::new(),
|
||||
closed: false,
|
||||
fit_opts: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -613,8 +614,8 @@ pub fn split_spline(sp: &Spline, t: f64) -> Option<(Spline, Spline)> {
|
||||
rk.extend(s.knots.get(first + p..)?.iter().copied());
|
||||
let rc = s.control.get(right_start..)?.to_vec();
|
||||
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(right_start..)?.to_vec() };
|
||||
let l = Spline { degree: p, knots: lk, control: lc, weights: lw, fit: Vec::new(), closed: false };
|
||||
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, fit: Vec::new(), closed: false };
|
||||
let l = Spline { degree: p, knots: lk, control: lc, weights: lw, ..Spline::default() };
|
||||
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, ..Spline::default() };
|
||||
(l.is_valid() && r.is_valid()).then_some((l, r))
|
||||
}
|
||||
|
||||
@@ -669,6 +670,12 @@ pub fn reverse_spline(sp: &Spline) -> Spline {
|
||||
weights: sp.weights.iter().rev().copied().collect(),
|
||||
fit: sp.fit.iter().rev().copied().collect(),
|
||||
closed: sp.closed,
|
||||
// The end tangents swap ends and turn round.
|
||||
fit_opts: cadcraft_geom::FitOptions {
|
||||
start_tangent: sp.fit_opts.end_tangent.map(|t| -t),
|
||||
end_tangent: sp.fit_opts.start_tangent.map(|t| -t),
|
||||
..sp.fit_opts
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+421
-170
@@ -2,11 +2,12 @@
|
||||
//! 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 cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::machines::number;
|
||||
use super::pline_opts::{Outcome, PlineAsk};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
|
||||
|
||||
@@ -40,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("polygon", "Polygon", run_polygon)
|
||||
.menu(&["Draw", "Polygon"])
|
||||
.alias(&["pol"])
|
||||
.params("{sides, center, radius, inscribed?: bool, angle?} | {sides, edge: [[x,y],[x,y]]}")
|
||||
.params("{sides, center, radius, inscribed?: bool, angle?: deg to a vertex (inscribed) or edge midpoint (default: bottom edge horizontal)} | {sides, edge: [[x,y],[x,y]]}")
|
||||
.interactive(|_| Ok(Box::new(PolygonM::default()))),
|
||||
CommandSpec::new("ellipse", "Ellipse", run_ellipse)
|
||||
.menu(&["Draw", "Ellipse", "Center"])
|
||||
@@ -64,11 +65,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("ray", "Ray", run_ray)
|
||||
.menu(&["Draw", "Ray"])
|
||||
.params("{base, through}")
|
||||
.interactive(|_| Ok(Box::new(XlineM { ray: true, base: None, fixed_angle: None }))),
|
||||
.interactive(|_| Ok(Box::new(RayM::default()))),
|
||||
CommandSpec::new("spline", "Spline", run_spline)
|
||||
.menu(&["Draw", "Spline", "Fit Points"])
|
||||
.alias(&["spl"])
|
||||
.params("{fit: [[x,y],...]} | {control: [...], degree?}")
|
||||
.params("{fit: [[x,y],...], closed?, knots?: chord|sqrt|uniform, startTangent?, endTangent? ([dx,dy]), tolerance?} | {control: [...], degree?} | {object: handle|true, handles?} (polyline to spline)")
|
||||
.interactive(|_| Ok(Box::new(SplineM::default()))),
|
||||
CommandSpec::new("donut", "Donut", run_donut)
|
||||
.menu(&["Draw", "Donut"])
|
||||
@@ -83,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("mtext", "Multiline Text", run_mtext)
|
||||
.menu(&["Draw", "Text", "Multiline Text..."])
|
||||
.alias(&["t", "mt"])
|
||||
.params("{at, text, height?, width?, attach?: 1..9, rotation?}")
|
||||
.params("{at, text, height?, width?, attach?: 1..9 | justify?: TL..BR, rotation?, style?, lineSpacing?, lineSpacingExact?}")
|
||||
.interactive(|_| Ok(Box::new(MTextM::default()))),
|
||||
]
|
||||
}
|
||||
@@ -99,6 +100,11 @@ fn run_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("line", "need at least 2 points"));
|
||||
}
|
||||
// A repeated point would make a zero-length line.
|
||||
let pts = distinct_points(&pts);
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("line", "need at least 2 different points"));
|
||||
}
|
||||
let mut hs = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
if let [a, b] = w {
|
||||
@@ -108,6 +114,7 @@ fn run_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "closed", false)
|
||||
&& pts.len() > 2
|
||||
&& let (Some(a), Some(b)) = (pts.last(), pts.first())
|
||||
&& !a.near(*b, 1e-12)
|
||||
{
|
||||
hs.push(s.add_entity(line(*a, *b))?.hex());
|
||||
}
|
||||
@@ -123,29 +130,30 @@ fn vertex_value(v: &Value) -> Option<PolyVertex> {
|
||||
}
|
||||
|
||||
fn run_pline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let vs: Vec<PolyVertex> = 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();
|
||||
let raw = p.get("vertices").or_else(|| p.get("points")).and_then(Value::as_array);
|
||||
for v in raw.into_iter().flatten() {
|
||||
size_param("pline", v, "startWidth", true)?;
|
||||
size_param("pline", v, "endWidth", true)?;
|
||||
}
|
||||
let vs: Vec<PolyVertex> = raw.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 width = size_param("pline", p, "width", true)?.unwrap_or(0.0);
|
||||
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);
|
||||
pl.const_width = width;
|
||||
}
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
fn run_circle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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"))?;
|
||||
// Checked before `Circle::new`, which would turn a negative radius positive.
|
||||
let r = match size_param("circle", p, "radius", false)? {
|
||||
Some(r) => r,
|
||||
None => size_param("circle", p, "diameter", false)?.ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))? / 2.0,
|
||||
};
|
||||
Circle::new(center, r)
|
||||
} else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
match point_param(p, "p3") {
|
||||
@@ -155,7 +163,7 @@ fn run_circle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
return Err(bad("circle", "give {center, radius} or {p1, p2[, p3]}"));
|
||||
};
|
||||
if c.radius <= 0.0 || !c.radius.is_finite() {
|
||||
if !(c.radius > 1e-12 && c.radius.is_finite()) {
|
||||
return Err(bad("circle", "radius must be positive"));
|
||||
}
|
||||
added(s.add_entity(circle(c.center, c.radius))?)
|
||||
@@ -168,6 +176,11 @@ fn run_arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Arc::from_start_center_end(st, c, e)
|
||||
} else if let Some(c) = point_param(p, "center") {
|
||||
let r = f64_req("arc", p, "radius")?;
|
||||
// Checked before `Arc::new`, which would turn a negative radius positive (zero is
|
||||
// refused below).
|
||||
if r < 0.0 {
|
||||
return Err(bad("arc", "`radius` must be positive"));
|
||||
}
|
||||
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}"));
|
||||
@@ -179,24 +192,30 @@ fn run_arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = p.get("sides").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
// A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4.
|
||||
let n = match p.get("sides") {
|
||||
None | Some(Value::Null) => 4,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0),
|
||||
};
|
||||
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")) };
|
||||
if a.near(*b, 1e-12) {
|
||||
return Err(bad("polygon", "edge endpoints must differ"));
|
||||
}
|
||||
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 r = size_param("polygon", p, "radius", false)?.ok_or_else(|| bad("polygon", "`radius` (number) is required"))?;
|
||||
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,
|
||||
)
|
||||
// Without an angle, the bottom edge is horizontal, as with a typed radius.
|
||||
let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) {
|
||||
Some(a) => Vec2::polar(c, r, a.to_radians()),
|
||||
None => polygon_typed_radius_point(c, n, r, inscribed),
|
||||
};
|
||||
polygon_vertices(c, n, rp, inscribed)
|
||||
};
|
||||
added(s.add_entity(lwpoly(vs, true))?)
|
||||
}
|
||||
@@ -236,16 +255,21 @@ fn run_ray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("object").is_some() {
|
||||
return super::spline_opts::run_object(s, p);
|
||||
}
|
||||
let sp = if let Some(f) = points_param(p, "fit") {
|
||||
if f.len() < 2 {
|
||||
return Err(bad("spline", "need 2+ fit points"));
|
||||
if distinct_points(&f).len() < 2 {
|
||||
return Err(bad("spline", "need 2+ different fit points"));
|
||||
}
|
||||
Spline::from_fit_points(&f)
|
||||
Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?)
|
||||
} 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)
|
||||
// Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows
|
||||
// with its square).
|
||||
Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize)
|
||||
} else {
|
||||
return Err(bad("spline", "`fit` or `control` points are required"));
|
||||
};
|
||||
@@ -264,7 +288,12 @@ fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind {
|
||||
|
||||
fn run_donut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)))?)
|
||||
let inside = size_param("donut", p, "inside", true)?.unwrap_or(0.5);
|
||||
let outside = size_param("donut", p, "outside", false)?.unwrap_or(1.0);
|
||||
if inside > outside {
|
||||
return Err(bad("donut", "`inside` must not be larger than `outside`"));
|
||||
}
|
||||
added(s.add_entity(donut(c, inside, outside))?)
|
||||
}
|
||||
|
||||
/// Justification code → (halign, valign).
|
||||
@@ -326,16 +355,24 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if height <= 0.0 {
|
||||
return Err(bad("mtext", "height must be positive"));
|
||||
}
|
||||
let style = s.doc()?.header.str("TEXTSTYLE", "Standard");
|
||||
let style = match str_param(p, "style") {
|
||||
Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?,
|
||||
None => s.doc()?.header.str("TEXTSTYLE", "Standard"),
|
||||
};
|
||||
let attach = match str_param(p, "justify") {
|
||||
Some(j) => super::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?,
|
||||
None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
|
||||
};
|
||||
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,
|
||||
attach,
|
||||
rotation: f64_or(p, "rotation", 0.0).to_radians(),
|
||||
style,
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: f64_or(p, "lineSpacing", 1.0),
|
||||
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
|
||||
});
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
@@ -444,19 +481,42 @@ impl Interactive for LineM {
|
||||
struct PlineM {
|
||||
verts: Vec<PolyVertex>,
|
||||
arc_mode: bool,
|
||||
width: f64,
|
||||
/// Total width at the start and end of the next segment (Width / Halfwidth answers).
|
||||
start_w: f64,
|
||||
end_w: f64,
|
||||
handle: Option<cadcraft_doc::Handle>,
|
||||
/// 1 = asking the starting width, 2 = the ending width.
|
||||
asking_width: u8,
|
||||
/// The widths are being asked as half-widths (centre line to edge).
|
||||
half: bool,
|
||||
/// An option asking further questions (arc mode's Angle, CEnter…, line mode's Length).
|
||||
ask: Option<PlineAsk>,
|
||||
}
|
||||
|
||||
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;
|
||||
// Every segment the same uniform width: store it as the constant width, as before.
|
||||
let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) };
|
||||
let w = pl.vertices.first().map_or(0.0, |v| v.start_width);
|
||||
if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) {
|
||||
pl.const_width = w;
|
||||
for v in &mut pl.vertices {
|
||||
(v.start_width, v.end_width) = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
k
|
||||
}
|
||||
/// The next segment, starting at the last vertex, takes the requested widths (#104); the
|
||||
/// segments after it are uniform at the ending width, as in AutoCAD.
|
||||
fn begin_segment(&mut self) {
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
(last.start_width, last.end_width) = (self.start_w, self.end_w);
|
||||
self.start_w = self.end_w;
|
||||
}
|
||||
}
|
||||
fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> {
|
||||
if self.verts.len() < 2 {
|
||||
if let Some(h) = self.handle.take() {
|
||||
@@ -473,11 +533,11 @@ impl PlineM {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Bulge for a tangent-continuing arc from the last vertex to `p`.
|
||||
fn tangent_bulge(&self, p: Vec2) -> f64 {
|
||||
/// The direction the polyline runs in at its last vertex (+X before the second vertex).
|
||||
fn tangent(&self) -> Vec2 {
|
||||
let n = self.verts.len();
|
||||
let Some(last) = self.verts.last() else { return 0.0 };
|
||||
let dir = if n >= 2 {
|
||||
let Some(last) = self.verts.last() else { return Vec2::X };
|
||||
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)) => {
|
||||
@@ -488,7 +548,12 @@ impl PlineM {
|
||||
}
|
||||
} else {
|
||||
Vec2::X
|
||||
};
|
||||
}
|
||||
}
|
||||
/// Bulge for a tangent-continuing arc from the last vertex to `p`.
|
||||
fn tangent_bulge(&self, p: Vec2) -> f64 {
|
||||
let Some(last) = self.verts.last() else { return 0.0 };
|
||||
let dir = self.tangent();
|
||||
let chord = p - last.p;
|
||||
// Included angle = 2 × angle between tangent and chord.
|
||||
let ang = dir.cross(chord).atan2(dir.dot(chord));
|
||||
@@ -500,12 +565,17 @@ impl Interactive for PlineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLINE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
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 unit = if self.half { "half-width" } else { "width" };
|
||||
let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) };
|
||||
let shown = if self.half { w / 2.0 } else { w };
|
||||
return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}"));
|
||||
}
|
||||
let base = self.verts.last().map(|v| v.p);
|
||||
if let (Some(a), Some(st)) = (self.ask, base) {
|
||||
return a.prompt(st, self.tangent(), s);
|
||||
}
|
||||
match (self.verts.len(), self.arc_mode) {
|
||||
(0, _) => Prompt::new("Specify start point", Accept::POINT),
|
||||
(n, false) => {
|
||||
@@ -524,20 +594,47 @@ impl Interactive for PlineM {
|
||||
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);
|
||||
let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||
// Halfwidth is centre line to edge: the total width is twice the answer (#83).
|
||||
let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 };
|
||||
if self.asking_width == 1 {
|
||||
self.start_w = w;
|
||||
} else {
|
||||
self.end_w = w;
|
||||
}
|
||||
}
|
||||
Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
if self.asking_width == 1 {
|
||||
// The ending width defaults to the starting width.
|
||||
self.end_w = self.start_w;
|
||||
}
|
||||
self.asking_width = if self.asking_width == 1 { 2 } else { 0 };
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let (Some(a), Some(st)) = (self.ask, self.verts.last().map(|v| v.p)) {
|
||||
match a.input(s, i, st, self.tangent())? {
|
||||
Outcome::Ask(next) => self.ask = Some(next),
|
||||
Outcome::Back => self.ask = None,
|
||||
Outcome::Segment(end, bulge) => {
|
||||
self.ask = None;
|
||||
self.begin_segment();
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
last.bulge = bulge;
|
||||
}
|
||||
self.verts.push(PolyVertex::new(end));
|
||||
self.sync(s, false)?;
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
if self.verts.is_empty() {
|
||||
s.echo(format!("Current line-width is {:.4}", self.width));
|
||||
s.echo(format!("Current line-width is {:.4}", self.start_w));
|
||||
}
|
||||
self.begin_segment();
|
||||
if self.arc_mode && !self.verts.is_empty() {
|
||||
let b = self.tangent_bulge(p);
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
@@ -566,6 +663,8 @@ impl Interactive for PlineM {
|
||||
last.bulge = b;
|
||||
}
|
||||
}
|
||||
// The closing segment starts at the last vertex.
|
||||
self.begin_segment();
|
||||
self.sync(s, true)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
@@ -573,16 +672,21 @@ impl Interactive for PlineM {
|
||||
self.verts.pop();
|
||||
if let Some(l) = self.verts.last_mut() {
|
||||
l.bulge = 0.0;
|
||||
(l.start_width, l.end_width) = (0.0, 0.0);
|
||||
}
|
||||
self.sync(s, false)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
"Width" | "Halfwidth" => {
|
||||
self.half = k == "Halfwidth";
|
||||
self.asking_width = 1;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => {
|
||||
s.echo(format!("{k}: not available yet"));
|
||||
k => {
|
||||
match PlineAsk::from_keyword(k, self.arc_mode) {
|
||||
Some(a) if !self.verts.is_empty() => self.ask = Some(a),
|
||||
_ => return Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
@@ -592,6 +696,12 @@ impl Interactive for PlineM {
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let Some(last) = self.verts.last() else { return Vec::new() };
|
||||
if let Some(a) = self.ask {
|
||||
return a
|
||||
.preview(last.p, self.tangent(), c)
|
||||
.map(|(e, b)| vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(e)], false)])
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if self.arc_mode {
|
||||
let b = self.tangent_bulge(c);
|
||||
vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)]
|
||||
@@ -697,6 +807,9 @@ impl Interactive for CircleM {
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
// Garbage at a point prompt: report it and ask again.
|
||||
Input::Text(_) if self.mode == 0 && self.pts.is_empty() => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
Input::Text(_) => Err(crate::EngineError::Other("Invalid point.".into())),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
@@ -748,9 +861,9 @@ impl Interactive for PolygonM {
|
||||
)
|
||||
.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(_), None) => Prompt::new("Enter an option", Accept::TEXT)
|
||||
.kw(&["Inscribed in circle", "Circumscribed about circle"])
|
||||
.default(if s.last_used.polygon_circumscribed { "C" } else { "I" }),
|
||||
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c),
|
||||
}
|
||||
}
|
||||
@@ -784,16 +897,23 @@ impl Interactive for PolygonM {
|
||||
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(_), None, inp @ (Input::Keyword(_) | Input::Enter | Input::Text(_))) => {
|
||||
// Enter takes the option used last time (the prompt's default).
|
||||
let ins = match inp {
|
||||
Input::Keyword(k) => k.starts_with('I'),
|
||||
Input::Text(t) => !t.trim().to_ascii_lowercase().starts_with('c'),
|
||||
_ => !s.last_used.polygon_circumscribed,
|
||||
};
|
||||
self.inscribed = Some(ins);
|
||||
s.last_used.polygon_circumscribed = !ins;
|
||||
}
|
||||
(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 };
|
||||
let rp = polygon_typed_radius_point(c, n, r, ins);
|
||||
s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
@@ -823,7 +943,11 @@ struct EllipseM {
|
||||
center_mode: bool,
|
||||
arc_mode: bool,
|
||||
pts: Vec<Vec2>,
|
||||
/// The `Rotation` option: the next value is the rotation around the major axis.
|
||||
rotation: bool,
|
||||
ellipse: Option<Ellipse>,
|
||||
/// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.
|
||||
param: bool,
|
||||
start: Option<f64>,
|
||||
}
|
||||
|
||||
@@ -847,6 +971,28 @@ impl EllipseM {
|
||||
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)))
|
||||
}
|
||||
/// The ellipse seen as a circle on the first axis rotated by `a` around it: ratio = cos(a),
|
||||
/// for rotations up to 89.4°.
|
||||
fn rotated(&self, a: f64) -> Option<Ellipse> {
|
||||
let (c, m) = self.axis()?;
|
||||
let ratio = a.cos().abs();
|
||||
(m.len() >= 1e-12 && ratio >= 89.4f64.to_radians().cos()).then_some(Ellipse::full(c, m, ratio.min(1.0)))
|
||||
}
|
||||
/// The full ellipse is known: draw it, or go on to the arc angles.
|
||||
fn place(&mut self, s: &mut Session, e: Ellipse) -> Result<Step> {
|
||||
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,
|
||||
}))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for EllipseM {
|
||||
@@ -855,30 +1001,46 @@ impl Interactive for EllipseM {
|
||||
}
|
||||
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);
|
||||
let msg = match (self.start.is_none(), self.param) {
|
||||
(true, false) => "Specify start angle",
|
||||
(false, false) => "Specify end angle",
|
||||
(true, true) => "Specify start parameter",
|
||||
(false, true) => "Specify end parameter",
|
||||
};
|
||||
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["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()),
|
||||
_ if self.rotation => Prompt::new("Specify rotation around major axis", Accept::POINT_OR_NUMBER).base_opt(self.axis().map(|a| a.0)),
|
||||
_ => 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<Step> {
|
||||
if let Some(e) = self.ellipse {
|
||||
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
|
||||
let ang = match i {
|
||||
Input::Point(p) => {
|
||||
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
|
||||
ge.param_of(p)
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Parameter" => self.param = true,
|
||||
"Angle" => self.param = false,
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// A parameter is the angle from the major axis taken as the parameter itself.
|
||||
Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()),
|
||||
Input::Point(p) => ge.param_of(p),
|
||||
Input::Text(t) => {
|
||||
// A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point.
|
||||
let a = crate::units::parse_angle(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?;
|
||||
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
|
||||
ge.param_of(e.center + Vec2::from_angle(a + e.major.angle()))
|
||||
let a = s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?;
|
||||
if self.param {
|
||||
cadcraft_geom::norm_angle(a)
|
||||
} else {
|
||||
// A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point.
|
||||
ge.param_of(e.center + Vec2::from_angle(a + e.major.angle()))
|
||||
}
|
||||
}
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
@@ -902,41 +1064,32 @@ impl Interactive for EllipseM {
|
||||
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::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true,
|
||||
Input::Point(p) if self.pts.len() < 2 => self.pts.push(p),
|
||||
Input::Point(p) if self.rotation => {
|
||||
if let Some(e) = self.axis().and_then(|(c, _)| self.rotated((p - c).angle())) {
|
||||
return self.place(s, e);
|
||||
}
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if let Some(e) = self.build(p) {
|
||||
return self.place(s, e);
|
||||
}
|
||||
}
|
||||
Input::Text(t) if self.pts.len() == 2 && self.rotation => {
|
||||
let e = s
|
||||
.angle_settings()
|
||||
.amount(&t)
|
||||
.and_then(|a| self.rotated(a))
|
||||
.ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?;
|
||||
return self.place(s, e);
|
||||
}
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return self.place(s, e);
|
||||
}
|
||||
}
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
@@ -948,17 +1101,17 @@ impl Interactive for EllipseM {
|
||||
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()
|
||||
let e = if self.rotation { self.axis().and_then(|(ctr, _)| self.rotated((c - ctr).angle())) } else { self.build(c) };
|
||||
e.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,45 +1145,31 @@ impl Interactive for PointM {
|
||||
}
|
||||
}
|
||||
|
||||
struct XlineM {
|
||||
ray: bool,
|
||||
/// RAY: a start point, then through points until Enter (XLINE is `draw2::XlineM2`).
|
||||
#[derive(Default)]
|
||||
struct RayM {
|
||||
base: Option<Vec2>,
|
||||
fixed_angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl Interactive for XlineM {
|
||||
impl Interactive for RayM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.ray { "RAY" } else { "XLINE" }
|
||||
"RAY"
|
||||
}
|
||||
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),
|
||||
match self.base {
|
||||
None => 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<Step> {
|
||||
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) })?;
|
||||
s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(d) }))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1041,14 +1180,8 @@ impl Interactive for XlineM {
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
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) }]
|
||||
}
|
||||
Some(b) if !b.near(c, 1e-12) => vec![EntityKind::Ray(RayLine { base: v3(b), dir: v3((c - b).normalized()) })],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1057,13 +1190,40 @@ impl Interactive for XlineM {
|
||||
#[derive(Default)]
|
||||
struct SplineM {
|
||||
pts: Vec<Vec2>,
|
||||
asking_method: bool,
|
||||
/// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`).
|
||||
delegate: Option<Box<dyn Interactive>>,
|
||||
/// Knots, end tangents and fit tolerance chosen so far.
|
||||
opts: cadcraft_geom::FitOptions,
|
||||
/// An option asking a further question (Knots, Tangency, toLerance, Object).
|
||||
ask: Option<super::spline_opts::SplineAsk>,
|
||||
}
|
||||
|
||||
impl SplineM {
|
||||
fn make(&self, closed: bool) -> Spline {
|
||||
Spline::fit_with(&self.pts, closed, self.opts)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for SplineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.opts.knots = super::spline_opts::current_knots(s);
|
||||
s.echo(format!("Current settings: Method=Fit Knots={}", self.opts.knots.name()));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.prompt(s);
|
||||
}
|
||||
if let Some(a) = &self.ask {
|
||||
return a.prompt(&self.opts, &self.pts);
|
||||
}
|
||||
if self.asking_method {
|
||||
return Prompt::new("Enter spline creation method", super::curves::KW).kw(&["Fit", "CV"]).default("Fit");
|
||||
}
|
||||
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()),
|
||||
@@ -1074,6 +1234,45 @@ impl Interactive for SplineM {
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(d) = &mut self.delegate {
|
||||
return d.input(s, i);
|
||||
}
|
||||
if let Some(a) = &mut self.ask {
|
||||
use super::spline_opts::Outcome;
|
||||
return match a.input(s, i, &mut self.opts, &self.pts)? {
|
||||
Outcome::Stay => Ok(Step::Continue),
|
||||
Outcome::Back => {
|
||||
self.ask = None;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Outcome::Finish => {
|
||||
self.ask = None;
|
||||
if self.pts.len() >= 2 {
|
||||
s.add_entity(EntityKind::Spline(self.make(false)))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Outcome::Done => Ok(Step::Done),
|
||||
};
|
||||
}
|
||||
if self.asking_method {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "CV" => {
|
||||
let Some(factory) = find_command("spline.cv").and_then(|c| c.interactive) else {
|
||||
return Err(crate::EngineError::Other("CV: not available yet".into()));
|
||||
};
|
||||
let mut m = factory(s)?;
|
||||
self.asking_method = false;
|
||||
let step = m.begin(s)?;
|
||||
self.delegate = Some(m);
|
||||
return Ok(step);
|
||||
}
|
||||
Input::Keyword(_) | Input::Enter => self.asking_method = false,
|
||||
Input::Text(_) => return Err(crate::EngineError::Other("Invalid option keyword.".into())),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
self.pts.push(p);
|
||||
@@ -1084,30 +1283,50 @@ impl Interactive for SplineM {
|
||||
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))?;
|
||||
// A smooth closed (periodic) curve, not a repeat of the first point.
|
||||
s.add_entity(EntityKind::Spline(self.make(true)))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Keyword(k) if k == "Method" => {
|
||||
self.asking_method = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
// Knots, Object, start/end Tangency, toLerance ask their own question.
|
||||
self.ask =
|
||||
Some(super::spline_opts::SplineAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
if self.pts.len() >= 2 {
|
||||
s.add_entity(EntityKind::Spline(Spline::from_fit_points(&self.pts)))?;
|
||||
s.add_entity(EntityKind::Spline(self.make(false)))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Text(_) => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.preview(s, c);
|
||||
}
|
||||
if self.pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut opts = self.opts;
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push(c);
|
||||
vec![EntityKind::Spline(Spline::from_fit_points(&pts))]
|
||||
match self.ask {
|
||||
// Rubber-band the tangent being picked.
|
||||
Some(super::spline_opts::SplineAsk::StartTangent) => opts.start_tangent = self.pts.first().map(|f| c - *f).filter(|d| d.len() > 1e-12),
|
||||
Some(super::spline_opts::SplineAsk::EndTangent) => opts.end_tangent = self.pts.last().map(|l| c - *l).filter(|d| d.len() > 1e-12),
|
||||
Some(_) => {}
|
||||
None => pts.push(c),
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![EntityKind::Spline(Spline::fit_with(&pts, false, opts))]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1227,7 +1446,7 @@ impl Interactive for TextM {
|
||||
(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()))?)
|
||||
self.rotation = Some(s.angle_settings().direction(&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);
|
||||
@@ -1246,40 +1465,66 @@ impl Interactive for TextM {
|
||||
struct MTextM {
|
||||
first: Option<Vec2>,
|
||||
second: Option<Vec2>,
|
||||
/// Paragraphs typed so far: one per input line, an empty line ends the text (as on
|
||||
/// AutoCAD's command line and in scripts).
|
||||
lines: Vec<String>,
|
||||
/// Height, justification, line spacing, rotation, style and width set by the options.
|
||||
opts: super::mtext_opts::MTextOpts,
|
||||
/// An option asking a further question.
|
||||
ask: Option<super::mtext_opts::MTextAsk>,
|
||||
}
|
||||
|
||||
impl MTextM {
|
||||
/// The box is known: the opposite corner was picked or the Width option answered.
|
||||
fn boxed(&self) -> bool {
|
||||
self.second.is_some() || self.opts.width.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MTextM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MTEXT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.opts = super::mtext_opts::MTextOpts::new(s);
|
||||
s.echo(format!("Current text style: \"{}\" Text height: {:.4} Annotative: No", self.opts.style, self.opts.height));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
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)
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
return q.prompt(&self.opts, a);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) if !self.boxed() => 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),
|
||||
_ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT),
|
||||
_ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.first, self.second, i) {
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
use super::mtext_opts::Outcome;
|
||||
self.ask = match q.input(s, i, &mut self.opts, a)? {
|
||||
Outcome::Ask(next) => Some(next),
|
||||
Outcome::Back | Outcome::Text => None,
|
||||
};
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.first, self.boxed(), 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,
|
||||
}))?;
|
||||
(Some(_), false, Input::Point(p)) => self.second = Some(p),
|
||||
(Some(_), false, Input::Keyword(k)) => {
|
||||
self.ask =
|
||||
Some(super::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?);
|
||||
}
|
||||
(Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t),
|
||||
(Some(a), true, Input::Text(_) | Input::Enter) => {
|
||||
if self.lines.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(_, _, Input::Enter) => return Ok(Step::Done),
|
||||
@@ -1288,8 +1533,14 @@ impl Interactive for MTextM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.first, self.second) {
|
||||
(Some(a), None) => vec![lwpoly(rect_vertices(a, c), true)],
|
||||
match (self.first, self.boxed(), self.ask) {
|
||||
(Some(a), false, None) => {
|
||||
// The box, turned by the rotation about the first corner.
|
||||
let r = self.opts.rotation;
|
||||
let l = (c - a).rotate(-r);
|
||||
let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)];
|
||||
vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +367,10 @@ impl Interactive for SeqM {
|
||||
let val = match (&i, ask) {
|
||||
(Input::Point(q), _) => self.value_from_point(ask, *q),
|
||||
(Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => {
|
||||
Some(Val::N(crate::units::parse_angle(t).ok_or_else(|| other("Requires a valid angle."))?))
|
||||
// The tangent direction is a direction; included angles are sizes.
|
||||
let au = s.angle_settings();
|
||||
let a = if self.v == ArcV::Sed { au.direction(t) } else { au.amount(t) };
|
||||
Some(Val::N(a.ok_or_else(|| other("Requires a valid angle."))?))
|
||||
}
|
||||
(Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)),
|
||||
(Input::Enter, _) => return Ok(Step::Cancel),
|
||||
@@ -1291,15 +1294,20 @@ fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result<Handle> {
|
||||
s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) }))
|
||||
}
|
||||
|
||||
/// A line-like object for XLINE Offset/Reference: (base, direction).
|
||||
fn linear_of(s: &Session, h: Handle) -> Result<(Vec2, Vec2)> {
|
||||
/// A line-like object for XLINE Offset/Reference: (base, direction). For a polyline, the
|
||||
/// segment nearest `pick` (the first without one), which must be straight.
|
||||
fn linear_of(s: &Session, h: Handle, pick: Option<Vec2>) -> Result<(Vec2, Vec2)> {
|
||||
let e = curves::entity(s, h)?;
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())),
|
||||
EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())),
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments();
|
||||
match segs.first() {
|
||||
let seg = match pick {
|
||||
Some(q) => segs.iter().min_by(|a, b| a.closest(q).dist(q).total_cmp(&b.closest(q).dist(q))),
|
||||
None => segs.first(),
|
||||
};
|
||||
match seg {
|
||||
Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())),
|
||||
_ => Err(other("Select a line object.")),
|
||||
}
|
||||
@@ -1330,7 +1338,7 @@ pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return added(add_xline(s, v, d)?);
|
||||
}
|
||||
if let Some(h) = curves::handle_param(p, "handle") {
|
||||
let (base, dir) = linear_of(s, h)?;
|
||||
let (base, dir) = linear_of(s, h, None)?;
|
||||
let (nb, nd) = if let Some(t) = point_param(p, "through") {
|
||||
(t, dir)
|
||||
} else {
|
||||
@@ -1449,18 +1457,18 @@ impl Interactive for XlineM2 {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::AngAsk, Input::Text(t)) => {
|
||||
let a = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?;
|
||||
let a = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?;
|
||||
self.mode = XMode::Fixed(a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::RefSelect, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let (_, d) = linear_of(s, h)?;
|
||||
let (_, d) = linear_of(s, h, Some(p))?;
|
||||
self.mode = XMode::RefAngle(d.angle());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::RefAngle(base), Input::Text(t)) => {
|
||||
let a = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?;
|
||||
let a = s.angle_settings().rotation(&t).ok_or_else(|| other("Requires a valid angle."))?;
|
||||
self.mode = XMode::Fixed(base + a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -1488,7 +1496,7 @@ impl Interactive for XlineM2 {
|
||||
}
|
||||
(XMode::OffSelect, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
self.line = Some(linear_of(s, h)?);
|
||||
self.line = Some(linear_of(s, h, Some(p))?);
|
||||
self.mode = XMode::OffSide;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -1557,7 +1565,11 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
|
||||
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let fillet = f64_or(p, "fillet", 0.0);
|
||||
if ch.0 < 0.0 || ch.1 < 0.0 {
|
||||
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
|
||||
}
|
||||
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
|
||||
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
|
||||
let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
|
||||
let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
|
||||
let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
|
||||
@@ -1586,7 +1598,7 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
|
||||
return Err(bad("rectang", "rectangle has no area"));
|
||||
}
|
||||
let k = rect_kind(a, w, h, rot, fillet, ch, f64_or(p, "width", 0.0), f64_or(p, "elevation", 0.0));
|
||||
let k = rect_kind(a, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
if th != 0.0 {
|
||||
@@ -1739,7 +1751,7 @@ impl Interactive for RectM2 {
|
||||
RAsk::Rotation => match i {
|
||||
Input::Keyword(_) => self.asking = Some(RAsk::RotPick1),
|
||||
Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0),
|
||||
Input::Text(t) => self.rotation = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?,
|
||||
Input::Text(t) => self.rotation = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?,
|
||||
_ => {}
|
||||
},
|
||||
RAsk::RotPick1 => {
|
||||
@@ -2352,7 +2364,7 @@ impl Interactive for SplineCvM {
|
||||
fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result<Handle> {
|
||||
let d = s.doc_mut()?;
|
||||
if d.linetype("CENTER").is_none()
|
||||
&& let Some(lt) = cadcraft_doc::library::standard_linetypes().into_iter().find(|l| l.name == "CENTER")
|
||||
&& let Some(lt) = cadcraft_doc::library::standard_linetypes_for(d).into_iter().find(|l| l.name == "CENTER")
|
||||
{
|
||||
d.linetypes.push(lt);
|
||||
}
|
||||
|
||||
+121
-28
@@ -10,7 +10,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("undo", "Undo", run_undo).menu(&["Edit", "Undo"]).key("Cmd+Z").alias(&["u"]).params("{count?: n}").noundo(),
|
||||
// U undoes the last operation at once (Edit > Undo, Cmd+Z); UNDO asks how many.
|
||||
CommandSpec::new("u", "Undo", run_u).menu(&["Edit", "Undo"]).key("Cmd+Z").params("{}").noundo(),
|
||||
CommandSpec::new("undo", "Undo", run_undo).params("{count?: n}").noundo().interactive(|_| Ok(Box::new(UndoM::default()))),
|
||||
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"])
|
||||
@@ -32,11 +34,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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("{}"),
|
||||
.params("{at?: [x,y]} (where the base point lands; default: the original coordinates)")
|
||||
.enabled(clipboard::has_clip)
|
||||
.interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Clip }))),
|
||||
CommandSpec::new("pasteblock", "Paste as Block", run_pasteblock)
|
||||
.menu(&["Edit", "Paste as Block"])
|
||||
.key("Cmd+Shift+V")
|
||||
.params("{at?: [x,y]} (insertion point; default: the original coordinates)")
|
||||
.enabled(clipboard::has_clip)
|
||||
.interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Block }))),
|
||||
CommandSpec::new("pasteorig", "Paste to Original Coordinates", run_pasteorig)
|
||||
.menu(&["Edit", "Paste to Original Coordinates"])
|
||||
.params("{} (only into a drawing other than the one copied from)")
|
||||
.enabled(clipboard::can_paste_orig)
|
||||
.interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Orig }))),
|
||||
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("ai_deselect", "Deselect All", run_deselect).params("{}").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()
|
||||
@@ -69,6 +83,63 @@ fn run_undo(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "undone": labels, "message": labels.join(", ").to_string() }))
|
||||
}
|
||||
|
||||
fn run_u(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
run_undo(s, &json!({}))
|
||||
}
|
||||
|
||||
/// UNDO at the command line: "Enter the number of operations to undo" (default 1). Its other
|
||||
/// options are not available yet; Auto and Control still take their value, so it isn't run as a
|
||||
/// command.
|
||||
#[derive(Default)]
|
||||
struct UndoM {
|
||||
/// Auto or Control was chosen: its value is being asked for.
|
||||
option: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Interactive for UndoM {
|
||||
fn name(&self) -> &'static str {
|
||||
"UNDO"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.option {
|
||||
Some("Auto") => Prompt::new("Enter UNDO Auto mode", Accept::TEXT).kw(&["ON", "OFF"]).default("ON"),
|
||||
Some(_) => Prompt::new("Enter an UNDO control option", Accept::TEXT).kw(&["All", "None", "One", "Combine", "Layer"]).default("All"),
|
||||
None => Prompt::new("Enter the number of operations to undo", Accept::NUMBER)
|
||||
.kw(&["Auto", "Control", "BEgin", "End", "Mark", "Back"])
|
||||
.default("1"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(o) = self.option {
|
||||
s.echo(format!("UNDO {o}: not available yet."));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let n = match i {
|
||||
Input::Text(t) => {
|
||||
t.trim().parse::<u64>().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))?
|
||||
}
|
||||
Input::Enter => 1,
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Auto" => self.option = Some("Auto"),
|
||||
"Control" => self.option = Some("Control"),
|
||||
_ => {
|
||||
s.echo(format!("UNDO {k}: not available yet."));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let r = run_undo(s, &json!({ "count": n }))?;
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
s.echo(m.to_string());
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_redo(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
end_running_command(s);
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000);
|
||||
@@ -86,9 +157,9 @@ fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<us
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = 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 base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
|
||||
let n = ents.len();
|
||||
s.clipboard = ents;
|
||||
clipboard::copy(s, ents, base)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -106,7 +177,8 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let mut hs = targets(s, p)?;
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let n = copy_to_clip(s, &hs, None)?;
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
@@ -116,23 +188,10 @@ fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "cut": n }))
|
||||
}
|
||||
|
||||
pub(crate) fn paste(s: &mut Session, offset: Vec2) -> Result<Vec<Handle>> {
|
||||
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)
|
||||
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
|
||||
/// objects need).
|
||||
fn paste(s: &mut Session, offset: Vec2) -> Result<Vec<Handle>> {
|
||||
clipboard::paste(s, &Mat3::translate(offset))
|
||||
}
|
||||
|
||||
fn run_paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -147,13 +206,24 @@ fn run_paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn run_pasteblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_param(p, "at").unwrap_or(s.clipboard_base);
|
||||
let h = clipboard::paste_block(s, at)?;
|
||||
let block = match s.doc()?.entity(h).map(|e| &e.kind) {
|
||||
Some(cadcraft_doc::EntityKind::Insert(i)) => i.block.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Ok(json!({ "handle": h.hex(), "block": block }))
|
||||
}
|
||||
|
||||
fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let r = paste(s, Vec2::ZERO)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let hs = s.selection();
|
||||
let mut hs = s.selection();
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
@@ -177,6 +247,11 @@ fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "selected": n }))
|
||||
}
|
||||
|
||||
fn run_deselect(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "selected": 0 }))
|
||||
}
|
||||
|
||||
fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "clear", false) {
|
||||
s.set_selection(Vec::new());
|
||||
@@ -202,20 +277,34 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum PasteMode {
|
||||
/// PASTECLIP: at an insertion point.
|
||||
Clip,
|
||||
/// PASTEBLOCK: as a block, at an insertion point.
|
||||
Block,
|
||||
/// PASTEORIG: at the original coordinates, without asking.
|
||||
Orig,
|
||||
}
|
||||
|
||||
struct PasteM {
|
||||
orig: bool,
|
||||
mode: PasteMode,
|
||||
}
|
||||
|
||||
impl Interactive for PasteM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PASTECLIP"
|
||||
match self.mode {
|
||||
PasteMode::Clip => "PASTECLIP",
|
||||
PasteMode::Block => "PASTEBLOCK",
|
||||
PasteMode::Orig => "PASTEORIG",
|
||||
}
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if s.clipboard.is_empty() {
|
||||
s.echo("Clipboard is empty.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
if self.orig {
|
||||
if self.mode == PasteMode::Orig {
|
||||
paste(s, Vec2::ZERO)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
@@ -226,6 +315,10 @@ impl Interactive for PasteM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) if self.mode == PasteMode::Block => {
|
||||
clipboard::paste_block(s, p)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let base = s.clipboard_base;
|
||||
paste(s, p - base)?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 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`].
|
||||
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. On the web, where there is
|
||||
//! no file system, saved files reach the user through the host's [`crate::cmd::file::set_deliver`].
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -29,6 +30,21 @@ pub fn io() -> Option<&'static IoHooks> {
|
||||
IO.get()
|
||||
}
|
||||
|
||||
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
|
||||
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
|
||||
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
|
||||
|
||||
static DELIVER: OnceLock<DeliverHook> = OnceLock::new();
|
||||
|
||||
/// Install the host's file delivery (the web app: a browser download). Saving uses it on wasm;
|
||||
/// anything else that produces a file on the web (plots) can use [`deliver`] too.
|
||||
pub fn set_deliver(h: DeliverHook) {
|
||||
let _ = DELIVER.set(h);
|
||||
}
|
||||
pub fn deliver() -> Option<DeliverHook> {
|
||||
DELIVER.get().copied()
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("new", "New Drawing...", run_new)
|
||||
@@ -46,8 +62,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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)
|
||||
CommandSpec::new("qsave", "Save", run_qsave_reporting).menu(&["File", "Save"]).key("Cmd+S").alias(&["save"]).params("{path?}").noundo(),
|
||||
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
|
||||
.menu(&["File", "Save As..."])
|
||||
.key("Cmd+Shift+S")
|
||||
.params("{path, format?: dxf|dwg}")
|
||||
@@ -99,6 +115,10 @@ fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.cancel();
|
||||
s.docs.remove(i);
|
||||
if i < s.active {
|
||||
// An earlier drawing went away: the active one shifted down by one.
|
||||
s.active -= 1;
|
||||
}
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
@@ -115,11 +135,37 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
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))?;
|
||||
commit_save(s, path, &bytes, store)
|
||||
}
|
||||
|
||||
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
|
||||
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[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::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let name = file_name(path);
|
||||
let deliver = deliver().ok_or_else(|| bad("save", "saving files is not available in this build; use document.bytes"))?;
|
||||
deliver(&name, "application/octet-stream", bytes).map_err(|e| bad("save", format!("{name}: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
|
||||
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
|
||||
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
|
||||
/// a later QSAVE downloads the drawing again under it. An image or PDF export (SVG, PNG, PDF) only
|
||||
/// stores the file: the drawing keeps its name, title and unsaved changes, so closing still asks.
|
||||
fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result<usize> {
|
||||
s.state()?;
|
||||
store(path, bytes)?;
|
||||
if !is_drawing_file(path) {
|
||||
return Ok(bytes.len());
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
st.saved = st.doc.clone();
|
||||
@@ -128,19 +174,28 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
/// The save result; on the web it also names the file the browser downloaded.
|
||||
fn saved(path: &str, bytes: usize) -> Value {
|
||||
let mut r = json!({ "path": path, "bytes": bytes });
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
r["download"] = json!(file_name(path));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 }))
|
||||
Ok(saved(&path, n))
|
||||
}
|
||||
|
||||
fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 }))
|
||||
Ok(saved(&path, n))
|
||||
}
|
||||
|
||||
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -156,8 +211,70 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 lost = not_saved(s.doc()?);
|
||||
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() }))
|
||||
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
|
||||
if is_drawing_file(&format!("drawing.{fmt}")) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model
|
||||
/// (MLINE, 3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but
|
||||
/// not written), counted by DXF type across model space, layouts and block definitions.
|
||||
pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
|
||||
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The command-line warning for [`not_saved`] entities, or `None` when the file keeps everything.
|
||||
pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Option<String> {
|
||||
if lost.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
|
||||
Some(format!("Not saved (CADCraft can't write these object types yet): {}", list.join(", ")))
|
||||
}
|
||||
|
||||
/// Whether `path` is saved as a drawing (DXF, DWG) rather than exported as an image.
|
||||
fn is_drawing_file(path: &str) -> bool {
|
||||
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "dxf" | "dwg" | "")
|
||||
}
|
||||
|
||||
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
|
||||
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
|
||||
let Some(msg) = not_saved_message(lost) else { return };
|
||||
s.echo(msg);
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("notSaved".into(), json!(lost));
|
||||
}
|
||||
}
|
||||
|
||||
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
|
||||
let lost = s.doc().map(not_saved).unwrap_or_default();
|
||||
let mut r = run(s, p)?;
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
save_reporting(s, p, run_save)
|
||||
}
|
||||
|
||||
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
save_reporting(s, p, run_saveas)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
@@ -201,3 +318,70 @@ pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn active_title(s: &Session) -> String {
|
||||
s.state().unwrap().title.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_inactive_drawing_keeps_active_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(active_title(&s), "Drawing2");
|
||||
s.execute("close", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(active_title(&s), "Drawing2");
|
||||
assert_eq!(s.docs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_later_inactive_drawing_keeps_active_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("close", &json!({"index": 2})).unwrap();
|
||||
assert_eq!(active_title(&s), "Drawing2");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pr193_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 4,4 1").unwrap();
|
||||
let st = s.state().unwrap();
|
||||
let title = st.title.clone();
|
||||
assert!(st.is_dirty() && st.path.is_none());
|
||||
|
||||
// The file route is unavailable or fails (the web without a download): no success, still dirty.
|
||||
let failed = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |_, _| Err(bad("save", "no download")));
|
||||
assert!(failed.is_err());
|
||||
let st = s.state().unwrap();
|
||||
assert!(st.is_dirty(), "a save that delivered nothing must keep the unsaved state");
|
||||
assert_eq!((st.path.as_deref(), st.title.as_str()), (None, title.as_str()));
|
||||
|
||||
// The file route took the bytes (written, or a download started): saved under that name.
|
||||
let mut got = None;
|
||||
let n = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |p, b| {
|
||||
got = Some((p.to_string(), b.len()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((n, got), (6, Some(("a/Owned.dxf".to_string(), 6))));
|
||||
let st = s.state().unwrap();
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!((st.path.as_deref(), st.title.as_str()), (Some("a/Owned.dxf"), "Owned.dxf"));
|
||||
assert_eq!(saved("a/Owned.dxf", n)["bytes"], 6);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("grip.move", "Grip Stretch", run_grip_move)
|
||||
.params("{handle, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)"),
|
||||
CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle, index, to}"),
|
||||
CommandSpec::new("grip.move", "Grip Stretch", run_grip_move).params(
|
||||
"{handle?, handles?, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)",
|
||||
),
|
||||
CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle?, handles?, index, to}"),
|
||||
CommandSpec::new("grip.rotate", "Grip Rotate", run_grip_rotate).params("{handles?, base | baseHandle+index, angle (degrees) | to, copy?}"),
|
||||
CommandSpec::new("grip.scale", "Grip Scale", run_grip_scale).params("{handles?, base | baseHandle+index, factor | to, copy?}"),
|
||||
CommandSpec::new("grip.mirror", "Grip Mirror", run_grip_mirror)
|
||||
@@ -37,8 +38,27 @@ fn stretch(s: &mut Session, cmd: &str, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let e = curves::entity(s, h)?;
|
||||
let k = stretch_grip(&e.kind, i, to).ok_or_else(|| bad(cmd, "no such grip, or the result would be degenerate"))?;
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
let mut edits = vec![(h, k)];
|
||||
// The other `handles` with a grip on the same point stretch with it (connected objects stay
|
||||
// connected); locked ones and edits that would be degenerate are left alone.
|
||||
let from = e.kind.grips().get(i).copied().unwrap_or(to);
|
||||
let tol = 1e-9 * (1.0 + from.len());
|
||||
for o in targets(s, p)? {
|
||||
if o == h || edits.iter().any(|(x, _)| *x == o) || curves::is_locked(s, o) {
|
||||
continue;
|
||||
}
|
||||
let Ok(oe) = curves::entity(s, o) else { continue };
|
||||
if let Some(j) = oe.kind.grips().iter().position(|g| g.near(from, tol))
|
||||
&& let Some(k) = stretch_grip(&oe.kind, j, to)
|
||||
{
|
||||
edits.push((o, k));
|
||||
}
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
for (o, k) in &edits {
|
||||
d.modify_entity(*o, |e| e.kind = k.clone())?;
|
||||
}
|
||||
Ok(json!({ "handle": h.hex(), "handles": edits.iter().map(|(o, _)| o.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Base point: `base`, or grip `index` of `baseHandle` (default: the first target).
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
//! Groups: GROUP, UNGROUP, GROUPEDIT, the group listing, and the post-command pass that drops
|
||||
//! members which no longer exist (an erased member leaves its groups; an empty group goes away).
|
||||
//!
|
||||
//! A group is a named (or unnamed, `*A1`, `*A2`…) ordered set of objects in the drawing's
|
||||
//! `groups`, saved as GROUP objects in the ACAD_GROUP dictionary. The JSON forms never prompt;
|
||||
//! typed, the commands ask as AutoCAD's command-line versions do.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Group, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// The most groups one drawing holds, and the most names one call lists.
|
||||
const MAX_GROUPS: usize = 100_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("group", "Group", run_group)
|
||||
.menu(&["Tools", "Group"])
|
||||
.alias(&["g"])
|
||||
.params("{name? (default: an unnamed group *A1, *A2…), description?, handles? (default the selection), selectable?: bool (default true)} → {name, members}")
|
||||
.interactive(|_| Ok(Box::new(GroupM::default()))),
|
||||
CommandSpec::new("ungroup", "Ungroup", run_ungroup)
|
||||
.menu(&["Tools", "Ungroup"])
|
||||
.params("{name?: \"G1\" | \"G1,G2\" | [names]} | {handles?} (default the selection: every group holding one of the objects) → {ungrouped: [names]}")
|
||||
.interactive(|_| Ok(Box::new(UngroupM::default()))),
|
||||
CommandSpec::new("groupedit", "Group Edit", run_groupedit)
|
||||
.params("{name, add?: [handles], remove?: [handles], rename?: new name} → {name, members}. Removing every member deletes the group.")
|
||||
.interactive(|_| Ok(Box::new(GroupEditM::default()))),
|
||||
CommandSpec::new("groups.list", "List Groups", run_list)
|
||||
.params("{} → {groups: [{name, description, selectable, unnamed, members: [handles]}]}")
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------- model helpers ----------------
|
||||
|
||||
/// A name a new or renamed group may take: not empty, not unnamed-style (`*…`), no characters
|
||||
/// that are special in names or wildcards.
|
||||
pub(crate) fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// Index of the group called `name` (case-insensitive).
|
||||
pub(crate) fn find(d: &Drawing, name: &str) -> Option<usize> {
|
||||
let n = name.trim();
|
||||
d.groups.iter().position(|g| g.name.eq_ignore_ascii_case(n))
|
||||
}
|
||||
|
||||
/// The groups holding `h`, most recently created first.
|
||||
pub(crate) fn groups_of(d: &Drawing, h: Handle) -> Vec<usize> {
|
||||
d.groups.iter().enumerate().rev().filter(|(_, g)| g.members.contains(&h)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// The first free unnamed group name: `*A1`, `*A2`…
|
||||
fn next_unnamed(d: &Drawing) -> String {
|
||||
let used: std::collections::HashSet<String> = d.groups.iter().map(|g| g.name.to_ascii_uppercase()).collect();
|
||||
(1..=d.groups.len() + 1).map(|n| format!("*A{n}")).find(|n| !used.contains(n)).unwrap_or_else(|| format!("*A{}", d.groups.len() + 1))
|
||||
}
|
||||
|
||||
/// `hs` without repeats and without handles of objects that don't exist.
|
||||
fn existing(d: &Drawing, hs: &[Handle]) -> Vec<Handle> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
hs.iter().copied().filter(|h| d.entity(*h).is_some() && seen.insert(*h)).collect()
|
||||
}
|
||||
|
||||
/// Make a group of `hs`. `name` None (or empty) makes an unnamed group. Returns its name.
|
||||
pub(crate) fn create(s: &mut Session, name: Option<&str>, description: &str, hs: &[Handle], selectable: bool) -> Result<String> {
|
||||
let d = s.doc()?;
|
||||
let members = existing(d, hs);
|
||||
if members.is_empty() {
|
||||
return Err(bad("group", "no objects to group"));
|
||||
}
|
||||
if d.groups.len() >= MAX_GROUPS {
|
||||
return Err(bad("group", "the drawing has too many groups"));
|
||||
}
|
||||
let name = match name.map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if !valid_name(n) => return Err(bad("group", format!("\"{n}\" is not a valid group name"))),
|
||||
Some(n) if find(d, n).is_some() => return Err(bad("group", format!("a group named \"{n}\" already exists"))),
|
||||
Some(n) => n.to_string(),
|
||||
None => next_unnamed(d),
|
||||
};
|
||||
s.doc_mut()?.groups.push(Group { name: name.clone(), description: description.to_string(), selectable, members });
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Remove the groups at `idx`; their objects stay. Returns the names removed.
|
||||
pub(crate) fn ungroup(s: &mut Session, idx: &[usize]) -> Result<Vec<String>> {
|
||||
let names: Vec<String> = idx.iter().filter_map(|i| s.doc().ok()?.groups.get(*i).map(|g| g.name.clone())).collect();
|
||||
if names.is_empty() {
|
||||
return Ok(names);
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let mut i = 0;
|
||||
d.groups.retain(|_| {
|
||||
let keep = !idx.contains(&i);
|
||||
i += 1;
|
||||
keep
|
||||
});
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Add `hs` (existing objects not already members) to the group at `i`. Returns how many joined.
|
||||
pub(crate) fn add_members(s: &mut Session, i: usize, hs: &[Handle]) -> Result<usize> {
|
||||
let add: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
let g = d.groups.get(i).ok_or_else(|| bad("groupedit", "no such group"))?;
|
||||
existing(d, hs).into_iter().filter(|h| !g.members.contains(h)).collect()
|
||||
};
|
||||
if !add.is_empty()
|
||||
&& let Some(g) = s.doc_mut()?.groups.get_mut(i)
|
||||
{
|
||||
g.members.extend(&add);
|
||||
}
|
||||
Ok(add.len())
|
||||
}
|
||||
|
||||
/// Remove `hs` from the group at `i`; a group left with no members is deleted. Returns how many
|
||||
/// left and whether the group was deleted.
|
||||
pub(crate) fn remove_members(s: &mut Session, i: usize, hs: &[Handle]) -> Result<(usize, bool)> {
|
||||
let g = s.doc()?.groups.get(i).ok_or_else(|| bad("groupedit", "no such group"))?;
|
||||
let n = g.members.iter().filter(|h| hs.contains(h)).count();
|
||||
if n == 0 {
|
||||
return Ok((0, false));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let empty = match d.groups.get_mut(i) {
|
||||
Some(g) => {
|
||||
g.members.retain(|h| !hs.contains(h));
|
||||
g.members.is_empty()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if empty {
|
||||
d.groups.remove(i);
|
||||
}
|
||||
Ok((n, empty))
|
||||
}
|
||||
|
||||
/// Rename the group at `i` (an unnamed group becomes a named one).
|
||||
pub(crate) fn rename(s: &mut Session, i: usize, new: &str) -> Result<()> {
|
||||
let new = new.trim();
|
||||
if !valid_name(new) {
|
||||
return Err(bad("groupedit", format!("\"{new}\" is not a valid group name")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if find(d, new).is_some_and(|j| j != i) {
|
||||
return Err(bad("groupedit", format!("a group named \"{new}\" already exists")));
|
||||
}
|
||||
if let Some(g) = s.doc_mut()?.groups.get_mut(i) {
|
||||
g.name = new.to_string();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One line per group for `?`: name, description, members.
|
||||
fn list_lines(d: &Drawing) -> Vec<String> {
|
||||
if d.groups.is_empty() {
|
||||
return vec!["No groups defined.".into()];
|
||||
}
|
||||
d.groups
|
||||
.iter()
|
||||
.take(MAX_GROUPS)
|
||||
.map(|g| {
|
||||
let n = g.members.iter().filter(|h| d.entity(**h).is_some()).count();
|
||||
let sel = if g.selectable { "selectable" } else { "not selectable" };
|
||||
if g.description.is_empty() {
|
||||
format!("{}: {n} object(s), {sel}", g.name)
|
||||
} else {
|
||||
format!("{}: {}, {n} object(s), {sel}", g.name, g.description)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn echo_list(s: &mut Session) -> Result<()> {
|
||||
for l in list_lines(s.doc()?) {
|
||||
s.echo(l);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The post-command pass: members that no longer exist (erased, moved into a block) leave their
|
||||
/// groups, and groups left empty are deleted. Cheap when the drawing has no groups or the
|
||||
/// command changed nothing.
|
||||
pub(crate) fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>) {
|
||||
let Ok(st) = s.state_mut() else { return };
|
||||
if st.doc.groups.is_empty() || before.is_some_and(|b| Arc::ptr_eq(b, &st.doc)) {
|
||||
return;
|
||||
}
|
||||
let d: &Drawing = &st.doc;
|
||||
let stale = d.groups.iter().any(|g| g.members.is_empty() || g.members.iter().any(|h| d.entity(*h).is_none()));
|
||||
if !stale {
|
||||
return;
|
||||
}
|
||||
let groups: Vec<Group> = d
|
||||
.groups
|
||||
.iter()
|
||||
.map(|g| Group { members: g.members.iter().copied().filter(|h| d.entity(*h).is_some()).collect(), ..g.clone() })
|
||||
.filter(|g| !g.members.is_empty())
|
||||
.collect();
|
||||
Arc::make_mut(&mut st.doc).groups = groups;
|
||||
}
|
||||
|
||||
// ---------------- JSON forms ----------------
|
||||
|
||||
/// Handles under `key` (hex strings or numbers); absent is empty.
|
||||
fn handles_at(cmd: &str, p: &Value, key: &str) -> Result<Vec<Handle>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(Vec::new()),
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.take(MAX_GROUPS * 10)
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.and_then(Handle::parse_hex)
|
||||
.or_else(|| v.as_u64().map(Handle))
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}`: {v} is not an object handle")))
|
||||
})
|
||||
.collect(),
|
||||
Some(v) => Err(bad(cmd, format!("`{key}` must be an array of object handles, got {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let name = create(s, str_param(p, "name"), str_param(p, "description").unwrap_or_default(), &hs, bool_or(p, "selectable", true))?;
|
||||
let n = s.doc()?.groups.iter().find(|g| g.name == name).map_or(0, |g| g.members.len());
|
||||
Ok(json!({ "name": name, "members": n }))
|
||||
}
|
||||
|
||||
fn run_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
let names: Option<Vec<String>> = match p.get("name") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(n)) => Some(n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()),
|
||||
Some(Value::Array(a)) => Some(
|
||||
a.iter()
|
||||
.take(MAX_GROUPS)
|
||||
.map(|v| v.as_str().map(|x| x.trim().to_string()).ok_or_else(|| bad("ungroup", "`name` must hold group names")))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Some(v) => return Err(bad("ungroup", format!("`name` must be a group name or an array of names, got {v}"))),
|
||||
};
|
||||
let mut idx: Vec<usize> = match names {
|
||||
Some(names) => names.iter().map(|n| find(d, n).ok_or_else(|| bad("ungroup", format!("no group named \"{n}\"")))).collect::<Result<_>>()?,
|
||||
None => targets(s, p)?.iter().flat_map(|h| groups_of(d, *h)).collect(),
|
||||
};
|
||||
idx.sort_unstable();
|
||||
idx.dedup();
|
||||
if idx.is_empty() {
|
||||
return Err(bad("ungroup", "the objects are not in a group"));
|
||||
}
|
||||
let names = ungroup(s, &idx)?;
|
||||
Ok(json!({ "ungrouped": names }))
|
||||
}
|
||||
|
||||
fn run_groupedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("groupedit", "`name` is required"))?;
|
||||
let i = find(s.doc()?, name).ok_or_else(|| bad("groupedit", format!("no group named \"{name}\"")))?;
|
||||
let add = handles_at("groupedit", p, "add")?;
|
||||
let remove = handles_at("groupedit", p, "remove")?;
|
||||
if let Some(h) = add.iter().find(|h| s.doc().is_ok_and(|d| d.entity(**h).is_none())) {
|
||||
return Err(bad("groupedit", format!("`add`: no object with handle {}", h.hex())));
|
||||
}
|
||||
let new_name = str_param(p, "rename");
|
||||
// Check the new name before changing anything.
|
||||
if let Some(n) = new_name {
|
||||
let n = n.trim();
|
||||
if !valid_name(n) || find(s.doc()?, n).is_some_and(|j| j != i) {
|
||||
return Err(bad("groupedit", format!("\"{n}\" is not a valid new group name, or a group of that name exists")));
|
||||
}
|
||||
}
|
||||
add_members(s, i, &add)?;
|
||||
let (_, deleted) = remove_members(s, i, &remove)?;
|
||||
if deleted {
|
||||
return Ok(json!({ "name": name, "members": 0, "deleted": true }));
|
||||
}
|
||||
if let Some(n) = new_name {
|
||||
rename(s, i, n)?;
|
||||
}
|
||||
let g = s.doc()?.groups.get(i).ok_or_else(|| bad("groupedit", "no such group"))?;
|
||||
Ok(json!({ "name": g.name, "members": g.members.len() }))
|
||||
}
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
let groups: Vec<Value> = d
|
||||
.groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let members: Vec<String> = g.members.iter().filter(|h| d.entity(**h).is_some()).map(|h| h.hex()).collect();
|
||||
json!({ "name": g.name, "description": g.description, "selectable": g.selectable, "unnamed": g.name.starts_with('*'), "members": members })
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "groups": groups }))
|
||||
}
|
||||
|
||||
// ---------------- prompts ----------------
|
||||
|
||||
/// The keyword typed or picked, in lower case.
|
||||
fn kw(i: &Input) -> Option<String> {
|
||||
match i {
|
||||
Input::Keyword(k) => Some(k.to_ascii_lowercase()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum GroupStage {
|
||||
#[default]
|
||||
Select,
|
||||
Name,
|
||||
Description,
|
||||
}
|
||||
|
||||
/// GROUP: "Select objects or [Name/Description]:", ending with Enter. With a pickfirst selection
|
||||
/// the unnamed group is made at once.
|
||||
#[derive(Default)]
|
||||
struct GroupM {
|
||||
stage: GroupStage,
|
||||
name: Option<String>,
|
||||
description: String,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl GroupM {
|
||||
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
match create(s, self.name.as_deref(), &self.description, hs, true) {
|
||||
Ok(n) if n.starts_with('*') => s.echo(format!("Unnamed group {n} created with {} object(s).", hs.len())),
|
||||
Ok(n) => s.echo(format!("Group \"{n}\" created with {} object(s).", hs.len())),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for GroupM {
|
||||
fn name(&self) -> &'static str {
|
||||
"GROUP"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.finish(s, &hs);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
GroupStage::Select if self.sel.removing => self.sel.prompt(),
|
||||
GroupStage::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["Name", "Description"]),
|
||||
GroupStage::Name => Prompt::new("Enter a group name", Accept::TEXT).kw(&["?"]),
|
||||
GroupStage::Description => Prompt::new("Enter group description", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
GroupStage::Select => match kw(&i).as_deref() {
|
||||
Some("name") => {
|
||||
self.stage = GroupStage::Name;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some("description") => {
|
||||
self.stage = GroupStage::Description;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
let step = self.finish(s, &hs);
|
||||
s.set_selection(Vec::new());
|
||||
step
|
||||
}
|
||||
},
|
||||
},
|
||||
GroupStage::Name => {
|
||||
match &i {
|
||||
Input::Keyword(k) if k == "?" => echo_list(s)?,
|
||||
Input::Text(t) => {
|
||||
let n = t.trim();
|
||||
if !valid_name(n) {
|
||||
s.echo(format!("\"{n}\" is not a valid group name."));
|
||||
} else if find(s.doc()?, n).is_some() {
|
||||
s.echo(format!("A group named \"{n}\" already exists."));
|
||||
} else {
|
||||
self.name = Some(n.to_string());
|
||||
self.stage = GroupStage::Select;
|
||||
}
|
||||
}
|
||||
// Enter keeps the group unnamed.
|
||||
Input::Enter => self.stage = GroupStage::Select,
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
GroupStage::Description => {
|
||||
match &i {
|
||||
Input::Text(t) => self.description = t.trim().to_string(),
|
||||
Input::Enter => self.description.clear(),
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.stage = GroupStage::Select;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The group a "Select group or [Name]" pick means: the most recent group of the first picked
|
||||
/// object that is in one.
|
||||
fn picked_group(d: &Drawing, hs: &[Handle]) -> Option<usize> {
|
||||
hs.iter().find_map(|h| groups_of(d, *h).first().copied())
|
||||
}
|
||||
|
||||
/// UNGROUP: "Select group or [Name]:". With a pickfirst selection, every group holding a
|
||||
/// selected object is ungrouped at once.
|
||||
#[derive(Default)]
|
||||
struct UngroupM {
|
||||
by_name: bool,
|
||||
}
|
||||
|
||||
impl UngroupM {
|
||||
fn done(s: &mut Session, idx: &[usize]) -> Result<Step> {
|
||||
for n in ungroup(s, idx)? {
|
||||
s.echo(format!("Group {n} ungrouped."));
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for UngroupM {
|
||||
fn name(&self) -> &'static str {
|
||||
"UNGROUP"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let pre = s.selection();
|
||||
if pre.is_empty() || !s.settings.pickfirst {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let mut idx: Vec<usize> = pre.iter().flat_map(|h| groups_of(d, *h)).collect();
|
||||
idx.sort_unstable();
|
||||
idx.dedup();
|
||||
if idx.is_empty() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Self::done(s, &idx)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.by_name {
|
||||
Prompt::new("Enter a group name", Accept::TEXT).kw(&["?"])
|
||||
} else {
|
||||
Prompt::new("Select group", Accept::SELECT).kw(&["Name"])
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.by_name {
|
||||
match &i {
|
||||
Input::Keyword(k) if k == "?" => echo_list(s)?,
|
||||
Input::Text(t) => match find(s.doc()?, t) {
|
||||
Some(g) => return Self::done(s, &[g]),
|
||||
None => s.echo(format!("No group named \"{}\".", t.trim())),
|
||||
},
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (&i, kw(&i).as_deref()) {
|
||||
(_, Some("name")) => self.by_name = true,
|
||||
(Input::Pick(hs), _) => match picked_group(s.doc()?, hs) {
|
||||
Some(g) => {
|
||||
s.set_selection(Vec::new());
|
||||
return Self::done(s, &[g]);
|
||||
}
|
||||
None => {
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(if hs.is_empty() { "0 found" } else { "The object is not a member of any group." });
|
||||
}
|
||||
},
|
||||
(Input::Enter, _) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum EditStage {
|
||||
#[default]
|
||||
Pick,
|
||||
Name,
|
||||
Option,
|
||||
Add,
|
||||
Remove,
|
||||
Rename,
|
||||
}
|
||||
|
||||
/// GROUPEDIT: pick a group (or name it), then Add objects, Remove objects or REName.
|
||||
#[derive(Default)]
|
||||
struct GroupEditM {
|
||||
stage: EditStage,
|
||||
/// The group being edited, by name (indices move when groups are deleted).
|
||||
group: String,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl GroupEditM {
|
||||
fn index(&self, s: &Session) -> Result<usize> {
|
||||
find(s.doc()?, &self.group).ok_or_else(|| bad("groupedit", format!("no group named \"{}\"", self.group)))
|
||||
}
|
||||
fn chosen(&mut self, s: &Session, i: usize) -> Result<()> {
|
||||
self.group = s.doc()?.groups.get(i).map(|g| g.name.clone()).unwrap_or_default();
|
||||
self.stage = EditStage::Option;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for GroupEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"GROUPEDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
EditStage::Pick => Prompt::new("Select group", Accept::SELECT).kw(&["Name"]),
|
||||
EditStage::Name => Prompt::new("Enter a group name", Accept::TEXT).kw(&["?"]),
|
||||
EditStage::Option => Prompt::new("Enter an option", super::curves::KW).kw(&["Add objects", "Remove objects", "REName"]),
|
||||
EditStage::Add if self.sel.removing => self.sel.prompt(),
|
||||
EditStage::Add => Prompt::new("Select objects to add to the group", Accept::SELECT),
|
||||
EditStage::Remove => Prompt::new("Select objects to remove from the group", Accept::SELECT),
|
||||
EditStage::Rename => Prompt::new("Enter a new name for the group", Accept::TEXT).kw(&["?"]).default(self.group.clone()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
EditStage::Pick => match (&i, kw(&i).as_deref()) {
|
||||
(_, Some("name")) => self.stage = EditStage::Name,
|
||||
(Input::Pick(hs), _) => {
|
||||
let g = picked_group(s.doc()?, hs);
|
||||
s.set_selection(Vec::new());
|
||||
match g {
|
||||
Some(g) => self.chosen(s, g)?,
|
||||
None => s.echo(if hs.is_empty() { "0 found" } else { "The object is not a member of any group." }),
|
||||
}
|
||||
}
|
||||
(Input::Enter, _) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
EditStage::Name => match &i {
|
||||
Input::Keyword(k) if k == "?" => echo_list(s)?,
|
||||
Input::Text(t) => match find(s.doc()?, t) {
|
||||
Some(g) => self.chosen(s, g)?,
|
||||
None => s.echo(format!("No group named \"{}\".", t.trim())),
|
||||
},
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
EditStage::Option => match kw(&i).as_deref() {
|
||||
Some("add objects") => self.stage = EditStage::Add,
|
||||
Some("remove objects") => self.stage = EditStage::Remove,
|
||||
Some("rename") => self.stage = EditStage::Rename,
|
||||
_ if i == Input::Enter => return Ok(Step::Done),
|
||||
_ => s.echo("Invalid option."),
|
||||
},
|
||||
EditStage::Add | EditStage::Remove => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => return Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
let g = self.index(s)?;
|
||||
if self.stage == EditStage::Add {
|
||||
let n = add_members(s, g, &hs)?;
|
||||
s.echo(format!("{n} object(s) added to group {}.", self.group));
|
||||
} else {
|
||||
match remove_members(s, g, &hs)? {
|
||||
(n, false) => s.echo(format!("{n} object(s) removed from group {}.", self.group)),
|
||||
(_, true) => s.echo(format!("Group {} has no objects left and was deleted.", self.group)),
|
||||
}
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
},
|
||||
EditStage::Rename => match &i {
|
||||
Input::Keyword(k) if k == "?" => echo_list(s)?,
|
||||
Input::Text(t) => {
|
||||
let g = self.index(s)?;
|
||||
match rename(s, g, t) {
|
||||
Ok(()) => {
|
||||
s.echo(format!("Group {} renamed to {}.", self.group, t.trim()));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
}
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
+101
-61
@@ -200,11 +200,14 @@ impl HatchSettings {
|
||||
gradient: self.gradient.clone(),
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||||
// A zero or negative pattern scale would be clamped to a near-zero spacing.
|
||||
size_param(if gradient { "gradient" } else { "hatch" }, p, "scale", false)?;
|
||||
let st = HatchSettings::from(s, p, gradient);
|
||||
let mut loops = Vec::new();
|
||||
if let Some(pts) = points_param(p, "points") {
|
||||
@@ -282,17 +285,77 @@ fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.execute("properties.set", &q)
|
||||
}
|
||||
|
||||
/// What a HATCH sub-prompt asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum HatchAsk {
|
||||
Pattern,
|
||||
Scale,
|
||||
Angle,
|
||||
}
|
||||
|
||||
struct HatchM {
|
||||
gradient: bool,
|
||||
settings: HatchSettings,
|
||||
loops: Vec<HatchLoop>,
|
||||
asking: Option<&'static str>,
|
||||
asking: Option<HatchAsk>,
|
||||
/// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again.
|
||||
selecting: bool,
|
||||
}
|
||||
|
||||
impl HatchM {
|
||||
fn new(s: &Session, gradient: bool) -> Self {
|
||||
HatchM { gradient, settings: HatchSettings::from(s, &Value::Null, gradient), loops: Vec::new(), asking: None, selecting: false }
|
||||
HatchM {
|
||||
gradient,
|
||||
settings: HatchSettings::from(s, &Value::Null, gradient),
|
||||
loops: Vec::new(),
|
||||
asking: None,
|
||||
selecting: s.last_used.hatch_select,
|
||||
}
|
||||
}
|
||||
fn set_mode(&mut self, s: &mut Session, selecting: bool) {
|
||||
self.selecting = selecting;
|
||||
s.last_used.hatch_select = selecting;
|
||||
}
|
||||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if !self.loops.is_empty() {
|
||||
let k = self.settings.hatch(std::mem::take(&mut self.loops));
|
||||
let h = s.add_entity(k)?;
|
||||
let space = s.space();
|
||||
if let Some(store) = s.doc_mut()?.space_mut(&space) {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> {
|
||||
match a {
|
||||
HatchAsk::Pattern => {
|
||||
let up = t.trim().to_ascii_uppercase();
|
||||
if cadcraft_doc::library::pattern(&up).is_some() {
|
||||
self.settings.pattern = up.clone();
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
s.echo(format!(
|
||||
"Unknown pattern {up}. Available: {}",
|
||||
cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
HatchAsk::Scale => {
|
||||
if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
}
|
||||
HatchAsk::Angle => {
|
||||
// Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1.
|
||||
if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) {
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,60 +372,36 @@ impl Interactive for HatchM {
|
||||
));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(a) = self.asking {
|
||||
return Prompt::new(a, Accept::TEXT);
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
// The sub-prompts offer the current value: Enter keeps it.
|
||||
match self.asking {
|
||||
Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()),
|
||||
Some(HatchAsk::Scale) => {
|
||||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||||
}
|
||||
Some(HatchAsk::Angle) => {
|
||||
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
||||
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
|
||||
let a = if s.angle_settings().clockwise { -self.settings.angle } else { self.settings.angle };
|
||||
let d = crate::units::format_angle(a, au, ap);
|
||||
return Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER).default(d);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if self.selecting {
|
||||
return Prompt::new("Select objects", Accept::SELECT);
|
||||
return Prompt::new("Select objects", Accept::SELECT).kw(&["picK internal point", "Undo", "seTtings", "sCale", "Angle"]);
|
||||
}
|
||||
Prompt::new("Pick internal point", Accept::POINT).kw(&["Select objects", "Undo", "seTtings", "Pattern", "sCale", "Angle"])
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
if let Input::Text(t) | Input::Keyword(t) = &i {
|
||||
match a {
|
||||
"Enter a pattern name" => {
|
||||
let up = t.trim().to_ascii_uppercase();
|
||||
if cadcraft_doc::library::pattern(&up).is_some() {
|
||||
self.settings.pattern = up.clone();
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
s.echo(format!(
|
||||
"Unknown pattern {up}. Available: {}",
|
||||
cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
"Specify a scale for the hatch pattern" => {
|
||||
if let Some(v) = super::machines::number(t).filter(|v| *v > 0.0) {
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(a) = crate::units::parse_angle(t) {
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.selecting {
|
||||
match i {
|
||||
Input::Pick(hs) => match loops_from_handles(s, &hs) {
|
||||
Ok(l) => self.loops.extend(l),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
},
|
||||
Input::Enter => self.selecting = false,
|
||||
_ => {}
|
||||
self.answer(s, a, t)?;
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
Input::Point(p) if !self.selecting => {
|
||||
match loops_at(s, p) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
|
||||
@@ -372,29 +411,30 @@ impl Interactive for HatchM {
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Select objects" => self.selecting = true,
|
||||
"Undo" => {
|
||||
self.loops.clear();
|
||||
Input::Pick(hs) => {
|
||||
match loops_from_handles(s, &hs) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("{} found", hs.len()));
|
||||
self.loops.extend(l);
|
||||
}
|
||||
"Pattern" | "seTtings" => self.asking = Some("Enter a pattern name"),
|
||||
"sCale" => self.asking = Some("Specify a scale for the hatch pattern"),
|
||||
_ => self.asking = Some("Specify an angle for the hatch pattern"),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
if !self.loops.is_empty() {
|
||||
let k = self.settings.hatch(std::mem::take(&mut self.loops));
|
||||
let h = s.add_entity(k)?;
|
||||
let space = s.space();
|
||||
if let Some(store) = s.doc_mut()?.space_mut(&space) {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Select objects" => self.set_mode(s, true),
|
||||
"picK internal point" => self.set_mode(s, false),
|
||||
"Undo" => self.loops.clear(),
|
||||
"Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern),
|
||||
"sCale" => self.asking = Some(HatchAsk::Scale),
|
||||
"Angle" => self.asking = Some(HatchAsk::Angle),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Done)
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// Enter ends boundary picking in either mode and creates the hatch.
|
||||
Input::Enter => self.finish(s),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed:
|
||||
(0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect()
|
||||
}
|
||||
|
||||
/// The radius point for a polygon whose radius is typed: the bottom edge is horizontal in both
|
||||
/// modes (inscribed: a vertex half a side's angle past straight down; circumscribed: the bottom
|
||||
/// edge's midpoint straight down).
|
||||
pub fn polygon_typed_radius_point(center: Vec2, sides: usize, radius: f64, inscribed: bool) -> Vec2 {
|
||||
let down = -std::f64::consts::FRAC_PI_2;
|
||||
let a = if inscribed { down + std::f64::consts::PI / sides.clamp(3, 1024) as f64 } else { down };
|
||||
Vec2::polar(center, radius, a)
|
||||
}
|
||||
|
||||
/// Polygon from an edge (first two vertices).
|
||||
pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec<PolyVertex> {
|
||||
let n = sides.clamp(3, 1024);
|
||||
|
||||
@@ -38,7 +38,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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("entities", "Query Entities", run_entities)
|
||||
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.noundo(),
|
||||
CommandSpec::new("count", "Count", run_count)
|
||||
.menu(&["Tools", "Count"])
|
||||
.params("{block?} → {blocks: {name: n}} | {block, count}, plus a `message` line")
|
||||
@@ -179,37 +181,45 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) => {
|
||||
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
|
||||
let geoms: Vec<Polyline> = hh.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect();
|
||||
let polys: Vec<Vec<Vec2>> = geoms.iter().map(|g| g.tessellate(1e-3)).collect();
|
||||
let mut total = (0.0, 0.0);
|
||||
for (i, g) in geoms.iter().enumerate() {
|
||||
let probe = polys.get(i).and_then(|p| p.first().copied());
|
||||
let depth =
|
||||
probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count());
|
||||
total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() };
|
||||
total.1 += g.len();
|
||||
}
|
||||
total
|
||||
}
|
||||
_ => return Err(bad("area", "object has no area")),
|
||||
}
|
||||
object_area(s, h)?
|
||||
};
|
||||
Ok(json!({ "area": a, "perimeter": per, "message": format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) }))
|
||||
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
|
||||
}
|
||||
|
||||
fn area_text(s: &Session, a: f64, per: f64) -> String {
|
||||
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
let d = s.doc()?;
|
||||
let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?;
|
||||
Ok(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) => {
|
||||
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
|
||||
let geoms: Vec<Polyline> = hh.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect();
|
||||
let polys: Vec<Vec<Vec2>> = geoms.iter().map(|g| g.tessellate(1e-3)).collect();
|
||||
let mut total = (0.0, 0.0);
|
||||
for (i, g) in geoms.iter().enumerate() {
|
||||
let probe = polys.get(i).and_then(|p| p.first().copied());
|
||||
let depth = probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count());
|
||||
total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() };
|
||||
total.1 += g.len();
|
||||
}
|
||||
total
|
||||
}
|
||||
_ => return Err(bad("area", "object has no area")),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_time(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -281,24 +291,43 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "entities";
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
given(k).map(|v| v.as_str().map(str::to_ascii_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
|
||||
};
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let ty = text("type")?;
|
||||
let layer = text("layer")?;
|
||||
let window = given("window")
|
||||
.map(|w| {
|
||||
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
|
||||
match corners.as_deref() {
|
||||
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
|
||||
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
|
||||
}
|
||||
})
|
||||
.transpose()?;
|
||||
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
|
||||
let limit = count("limit", 500)?;
|
||||
let offset = count("offset", 0)?;
|
||||
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<Value> = store
|
||||
let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) };
|
||||
let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
|
||||
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = 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))
|
||||
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol)))
|
||||
.collect();
|
||||
Ok(json!({ "count": matches.len(), "entities": matches }))
|
||||
let page: Vec<Value> = matches.iter().skip(offset).take(limit).map(|e| entity_summary(e)).collect();
|
||||
// `count` is every match; `returned` is this page of them (`offset`, `limit`).
|
||||
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
|
||||
}
|
||||
|
||||
fn run_count(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -330,6 +359,47 @@ fn run_count(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[derive(Default)]
|
||||
struct DistM {
|
||||
first: Option<Vec2>,
|
||||
/// Multiple points: the points so far, the first included.
|
||||
multi: Option<Vec<Vec2>>,
|
||||
/// Asking for the length of a segment (the Length option).
|
||||
length: bool,
|
||||
}
|
||||
|
||||
/// Total length of the segments through `pts`.
|
||||
fn path_len(pts: &[Vec2]) -> f64 {
|
||||
pts.windows(2)
|
||||
.map(|w| match w {
|
||||
[a, b] => a.dist(*b),
|
||||
_ => 0.0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single
|
||||
/// point): the Length option of AREA and DIST's point sequences.
|
||||
fn extend_last(pts: &[Vec2], len: f64) -> Option<Vec2> {
|
||||
let last = *pts.last()?;
|
||||
let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) {
|
||||
Some(prev) => (last - *prev).normalized(),
|
||||
None => Vec2::X,
|
||||
};
|
||||
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
|
||||
let p = last + dir * len;
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
|
||||
fn length_prompt() -> Prompt {
|
||||
Prompt::new("Specify length of line", Accept::NUMBER)
|
||||
}
|
||||
|
||||
/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter).
|
||||
fn length_input(pts: &[Vec2], i: Input) -> Result<Option<Vec2>> {
|
||||
let t = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||
Ok(extend_last(pts, len))
|
||||
}
|
||||
|
||||
impl Interactive for DistM {
|
||||
@@ -337,12 +407,49 @@ impl Interactive for DistM {
|
||||
"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),
|
||||
if self.length {
|
||||
return length_prompt();
|
||||
}
|
||||
match (&self.multi, self.first) {
|
||||
(Some(pts), _) if pts.len() < 2 => {
|
||||
Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied())
|
||||
}
|
||||
(Some(pts), _) => Prompt::new("Specify next point", Accept::POINT)
|
||||
.kw(&["Arc", "Length", "Undo", "Total"])
|
||||
.default("Total")
|
||||
.base_opt(pts.last().copied()),
|
||||
(None, None) => Prompt::new("Specify first point", Accept::POINT),
|
||||
(None, Some(a)) => Prompt::new("Specify second point", Accept::POINT).kw(&["Multiple points"]).base(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(pts) = &mut self.multi {
|
||||
if self.length {
|
||||
if let Some(p) = length_input(pts, i)? {
|
||||
pts.push(p);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(pts))));
|
||||
}
|
||||
self.length = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
pts.push(p);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(pts))));
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if pts.len() > 1 {
|
||||
pts.pop();
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if k == "Length" => self.length = true,
|
||||
Input::Keyword(k) if k == "Total" => return self.total(s),
|
||||
Input::Keyword(k) => s.echo(format!("{k}: not available yet")),
|
||||
Input::Enter => return self.total(s),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.first, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
self.first = Some(p);
|
||||
@@ -355,12 +462,31 @@ impl Interactive for DistM {
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
|
||||
self.multi = Some(vec![a]);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default()
|
||||
match &self.multi {
|
||||
Some(pts) => {
|
||||
let mut v: Vec<cadcraft_geom::PolyVertex> = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
|
||||
v.push(cadcraft_geom::PolyVertex::new(c));
|
||||
vec![super::helpers::lwpoly(v, false)]
|
||||
}
|
||||
None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DistM {
|
||||
fn total(&self, s: &mut Session) -> Result<Step> {
|
||||
let d = self.multi.as_deref().map_or(0.0, path_len);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, d)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,9 +551,56 @@ impl Interactive for ListM {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum AreaMode {
|
||||
/// Measure one area and end.
|
||||
#[default]
|
||||
Single,
|
||||
/// Add each measured area to the running total.
|
||||
Add,
|
||||
/// Subtract each measured area from the running total.
|
||||
Subtract,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AreaM {
|
||||
pts: Vec<Vec2>,
|
||||
mode: AreaMode,
|
||||
total: f64,
|
||||
/// Selecting objects (the Object option).
|
||||
object: bool,
|
||||
/// Asking for the length of a segment (the Length option).
|
||||
length: bool,
|
||||
}
|
||||
|
||||
impl AreaM {
|
||||
fn mode_prefix(&self) -> &'static str {
|
||||
match self.mode {
|
||||
AreaMode::Single => "",
|
||||
AreaMode::Add => "(ADD mode) ",
|
||||
AreaMode::Subtract => "(SUBTRACT mode) ",
|
||||
}
|
||||
}
|
||||
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
|
||||
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
||||
s.echo(area_text(s, a, per));
|
||||
match self.mode {
|
||||
AreaMode::Single => return Step::Done,
|
||||
AreaMode::Add => self.total += a,
|
||||
AreaMode::Subtract => self.total -= a,
|
||||
}
|
||||
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
/// Close the boundary picked so far (Enter or Total at the next-point prompt).
|
||||
fn close(&mut self, s: &mut Session) -> Step {
|
||||
let pts = std::mem::take(&mut self.pts);
|
||||
if pts.len() < 3 {
|
||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||
}
|
||||
let (a, per) = area_of(&pts);
|
||||
self.measured(s, a, per)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AreaM {
|
||||
@@ -435,37 +608,81 @@ impl Interactive for AreaM {
|
||||
"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()),
|
||||
if self.object {
|
||||
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
|
||||
}
|
||||
if self.length {
|
||||
return length_prompt();
|
||||
}
|
||||
let next = format!("{}Specify next point", self.mode_prefix());
|
||||
match (self.pts.len(), self.mode) {
|
||||
(0, AreaMode::Single) => {
|
||||
Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object")
|
||||
}
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]),
|
||||
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new(next, 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<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
self.pts.push(p);
|
||||
Ok(Step::Continue)
|
||||
if self.object {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
for h in hs {
|
||||
match object_area(s, h) {
|
||||
Ok((a, per)) => {
|
||||
if self.measured(s, a, per) == Step::Done {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
Err(_) => s.echo("The selected object has no area."),
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Enter => {
|
||||
self.object = false;
|
||||
if self.mode == AreaMode::Single {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"),
|
||||
_ => {}
|
||||
}
|
||||
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),
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.length {
|
||||
if let Some(p) = length_input(&self.pts, i)? {
|
||||
self.pts.push(p);
|
||||
}
|
||||
self.length = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Object" => self.object = true,
|
||||
"Add area" => self.mode = AreaMode::Add,
|
||||
"Subtract area" => self.mode = AreaMode::Subtract,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
"Length" => self.length = true,
|
||||
"Total" => return Ok(self.close(s)),
|
||||
_ => s.echo(format!("{k}: not available yet")),
|
||||
},
|
||||
Input::Enter if self.pts.is_empty() => match self.mode {
|
||||
// Enter takes the default, Object; in add/subtract mode it ends the command.
|
||||
AreaMode::Single => self.object = true,
|
||||
AreaMode::Add | AreaMode::Subtract => return Ok(Step::Done),
|
||||
},
|
||||
Input::Enter => return Ok(self.close(s)),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.pts.is_empty() {
|
||||
if self.pts.is_empty() || self.object || self.length {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut v: Vec<cadcraft_geom::PolyVertex> = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
|
||||
|
||||
+134
-18
@@ -1,4 +1,6 @@
|
||||
//! Layers: LAYER (programmatic sub-ids), layer tools (LAYMCUR, LAYISO, LAYOFF, LAYFRZ…).
|
||||
//! Layers: LAYER (programmatic sub-ids, -LAYER prompts), layer tools (LAYMCUR, LAYISO, LAYOFF, LAYFRZ…).
|
||||
|
||||
mod prompts;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Layer, Lineweight};
|
||||
@@ -10,10 +12,14 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("layer", "Layers", run_list).menu(&["Format", "Layers"]).alias(&["la", "layers"]).noundo(),
|
||||
CommandSpec::new("layer", "Layers", run_list)
|
||||
.menu(&["Format", "Layers"])
|
||||
.alias(&["la", "layers", "-layer"])
|
||||
.params("{} → layers, states (typed -LAYER: option prompts)")
|
||||
.interactive(|_| Ok(Box::new(prompts::LayerM::default()))),
|
||||
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?, newVpFreeze?, newName?}"),
|
||||
.params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, plotStyle? (named plot style), newName?}"),
|
||||
CommandSpec::new("layer.current", "Make Current", run_current)
|
||||
.menu(&["Format", "Layer Tools", "Make Current"])
|
||||
.alias(&["clayer"])
|
||||
@@ -51,6 +57,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("layerstate.list", "List Layer States", run_state_list).params("{} → states").noundo(),
|
||||
CommandSpec::new("layerstate.delete", "Delete Layer State", run_state_delete).params("{name}"),
|
||||
CommandSpec::new("layerstate.rename", "Rename Layer State", run_state_rename).params("{from, to}"),
|
||||
CommandSpec::new("laymrg", "Layer Merge", merge::run_laymrg)
|
||||
.menu(&["Format", "Layer Tools", "Layer Merge"])
|
||||
.params("{from: [names], to} (the from layers' objects move to `to`, then the from layers are deleted)")
|
||||
.interactive(|_| Ok(Box::new(merge::LayerRemove::merge()))),
|
||||
CommandSpec::new("laydel", "Layer Delete", merge::run_laydel)
|
||||
.menu(&["Format", "Layer Tools", "Layer Delete"])
|
||||
.params("{names: [...]} (deletes the layers and every object on them)")
|
||||
.interactive(|_| Ok(Box::new(merge::LayerRemove::delete()))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,7 +73,7 @@ fn layer_json(l: &Layer, current: bool) -> Value {
|
||||
"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, "newVpFreeze": l.vp_freeze_new,
|
||||
"description": l.description, "current": current, "newVpFreeze": l.vp_freeze_new, "plotStyle": l.plot_style,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,6 +132,12 @@ fn apply(l: &mut Layer, p: &Value) -> Result<()> {
|
||||
if let Some(v) = p.get("plot").and_then(Value::as_bool) {
|
||||
l.plot = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "plotStyle").map(str::trim) {
|
||||
if v.is_empty() || v.eq_ignore_ascii_case("bylayer") || v.eq_ignore_ascii_case("byblock") {
|
||||
return Err(bad("layer", "a layer's plot style is a style name (such as Normal)"));
|
||||
}
|
||||
l.plot_style = v.chars().take(255).collect();
|
||||
}
|
||||
if let Some(c) = p.get("color") {
|
||||
let c = c
|
||||
.as_str()
|
||||
@@ -147,6 +167,17 @@ fn apply(l: &mut Layer, p: &Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The loaded linetype named by `linetype`, if given: a layer cannot use ByLayer, ByBlock or a
|
||||
/// linetype the drawing does not have.
|
||||
fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result<Option<String>> {
|
||||
let Some(lt) = str_param(p, "linetype") else { return Ok(None) };
|
||||
if lt.eq_ignore_ascii_case("ByLayer") || lt.eq_ignore_ascii_case("ByBlock") {
|
||||
return Err(bad(cmd, "a layer linetype cannot be ByLayer or ByBlock"));
|
||||
}
|
||||
let t = d.linetype(lt).ok_or_else(|| bad(cmd, format!("linetype `{lt}` is not loaded (load it with LINETYPE)")))?;
|
||||
Ok(Some(t.name.clone()))
|
||||
}
|
||||
|
||||
fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
@@ -160,8 +191,12 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if d.layer(&name).is_some() {
|
||||
return Err(bad("layer.new", format!("layer `{name}` already exists")));
|
||||
}
|
||||
let lt = loaded_linetype(d, "layer.new", p)?;
|
||||
let mut l = Layer::new(&name);
|
||||
apply(&mut l, p)?;
|
||||
if let Some(lt) = lt {
|
||||
l.linetype = lt;
|
||||
}
|
||||
d.layers.push(l);
|
||||
if bool_or(p, "current", false) {
|
||||
d.header.set_str("CLAYER", &name);
|
||||
@@ -172,27 +207,45 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()?;
|
||||
// Validate on a copy before borrowing the drawing mutably, so a rejected call
|
||||
// leaves the layer untouched and records no undo step.
|
||||
let d = s.doc()?;
|
||||
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;
|
||||
let mut updated = d.layer(&name).cloned().ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?;
|
||||
let old = updated.name.clone();
|
||||
let lt = loaded_linetype(d, "layer.set", p)?;
|
||||
apply(&mut updated, p)?;
|
||||
if let Some(lt) = lt {
|
||||
updated.linetype = lt;
|
||||
}
|
||||
if p.get("frozen").and_then(Value::as_bool) == Some(true) && old.eq_ignore_ascii_case(&cur) {
|
||||
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") {
|
||||
if let Some(nn) = &new_name {
|
||||
if old == "0" || old.eq_ignore_ascii_case("Defpoints") {
|
||||
return Err(bad("layer.set", "cannot rename layer 0 or Defpoints"));
|
||||
}
|
||||
if !valid_name(&nn) {
|
||||
if !valid_name(nn) {
|
||||
return Err(bad("layer.set", "invalid layer name"));
|
||||
}
|
||||
let old = l.name.clone();
|
||||
if !nn.eq_ignore_ascii_case(&old) && d.layer(nn).is_some() {
|
||||
return Err(bad("layer.set", format!("layer `{nn}` already exists")));
|
||||
}
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let l = d.layer_mut(&old).ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?;
|
||||
*l = updated;
|
||||
if let Some(nn) = new_name {
|
||||
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());
|
||||
// Re-point entities in model space, every layout and every block definition.
|
||||
repoint(&mut d.model, &old, &nn);
|
||||
for layout in &mut d.layouts {
|
||||
repoint(&mut layout.entities, &old, &nn);
|
||||
}
|
||||
for block in d.blocks.values_mut() {
|
||||
if block.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&old)) {
|
||||
repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn);
|
||||
}
|
||||
}
|
||||
if cur.eq_ignore_ascii_case(&old) {
|
||||
d.header.set_str("CLAYER", &nn);
|
||||
@@ -201,6 +254,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn repoint(store: &mut cadcraft_doc::EntityStore, old: &str, new: &str) {
|
||||
let hs: Vec<_> = store.iter().filter(|e| e.common.layer.eq_ignore_ascii_case(old)).map(|e| e.handle).collect();
|
||||
for h in hs {
|
||||
store.modify(h, |e| e.common.layer = new.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("layer.current", "`name` is required"))?.to_string();
|
||||
let d = s.doc_mut()?;
|
||||
@@ -232,7 +292,8 @@ fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)));
|
||||
|| d.layouts.iter().any(|l| l.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name)))
|
||||
|| d.blocks.values().any(|b| b.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name)));
|
||||
if used {
|
||||
return Err(bad("layer.delete", "layer has objects on it"));
|
||||
}
|
||||
@@ -386,6 +447,9 @@ fn run_state_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
// LAYMRG and LAYDEL.
|
||||
mod merge;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -414,4 +478,56 @@ mod tests {
|
||||
let used: Vec<bool> = v["layers"].as_array().unwrap().iter().map(|l| l["used"].as_bool().unwrap()).collect();
|
||||
assert!(used.contains(&true) && used.contains(&false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_layer_set_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
// Layer 0 is current; freezing it is rejected and must not apply the colour either.
|
||||
let before = s.doc().unwrap().layer("0").unwrap().clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
assert!(s.execute("layer.set", &json!({ "name": "0", "frozen": true, "color": 3 })).is_err());
|
||||
assert_eq!(s.doc().unwrap().layer("0").unwrap(), &before);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for a rejected call");
|
||||
// Same for a rejected rename.
|
||||
assert!(s.execute("layer.set", &json!({ "name": "0", "newName": "X", "color": 3 })).is_err());
|
||||
assert_eq!(s.doc().unwrap().layer("0").unwrap(), &before);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for a rejected call");
|
||||
// A valid colour-only edit of the current layer still applies.
|
||||
s.execute("layer.set", &json!({ "name": "0", "color": 3 })).unwrap();
|
||||
assert_ne!(s.doc().unwrap().layer("0").unwrap(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_purge_keep_layers_used_by_block_definitions() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({ "name": "A", "color": 1 })).unwrap();
|
||||
s.execute("layer.current", &json!({ "name": "A" })).unwrap();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap();
|
||||
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "B", "at": [5, 5] })).unwrap();
|
||||
assert!(s.execute("layer.delete", &json!({ "name": "A" })).is_err());
|
||||
s.execute("purge", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().layer("A").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_repoints_paper_space_and_block_entities() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({ "name": "A", "color": "red", "current": true })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
s.execute("block", &json!({ "name": "Part", "base": [0, 0], "keep": "delete" })).unwrap();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
|
||||
s.execute("layer.set", &json!({ "name": "A", "newName": "B" })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let on_layer = |name: &str| {
|
||||
let paper = d.layouts.iter().flat_map(|l| l.entities.iter());
|
||||
let blocks = d.blocks.values().flat_map(|b| b.entities.iter());
|
||||
paper.chain(blocks).filter(|e| e.common.layer == name).count()
|
||||
};
|
||||
assert_eq!(on_layer("A"), 0);
|
||||
assert_eq!(on_layer("B"), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! LAYMRG and LAYDEL: merge layers into another layer, and delete a layer together with every
|
||||
//! object on it (model space, layouts and block definitions).
|
||||
//!
|
||||
//! The JSON forms (`laymrg {from, to}`, `laydel {names}`) never prompt. Typed, both pick layers by
|
||||
//! selecting objects on them (or by Name), then ask for confirmation before changing anything.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::{bad, find_command, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Layers whose names carry `|` belong to an attached xref and are managed by it.
|
||||
fn xref_layer(name: &str) -> bool {
|
||||
name.contains('|')
|
||||
}
|
||||
|
||||
fn protected(name: &str) -> bool {
|
||||
name == "0" || name.eq_ignore_ascii_case("Defpoints")
|
||||
}
|
||||
|
||||
/// `key` as one name or a list of names.
|
||||
fn names_param(p: &Value, key: &str) -> Vec<String> {
|
||||
match p.get(key) {
|
||||
Some(Value::String(s)) => vec![s.clone()],
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).take(10_000).map(str::to_string).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(n: usize, one: &str) -> String {
|
||||
if n == 1 { format!("1 {one}") } else { format!("{n} {one}s") }
|
||||
}
|
||||
|
||||
/// Why `name` can't be merged away or deleted (`None`: it can). `delete`: LAYDEL's rules, which
|
||||
/// also keep the current layer.
|
||||
fn source_refusal(d: &Drawing, name: &str, delete: bool) -> Option<String> {
|
||||
let verb = if delete { "delete" } else { "merge" };
|
||||
if protected(name) {
|
||||
return Some(format!("Cannot {verb} layer \"{name}\" (layer 0 and Defpoints always stay)."));
|
||||
}
|
||||
if xref_layer(name) {
|
||||
return Some(format!("Cannot {verb} xref-dependent layer \"{name}\"."));
|
||||
}
|
||||
if delete && d.header.str("CLAYER", "0").eq_ignore_ascii_case(name) {
|
||||
return Some(format!("Cannot delete the current layer \"{name}\"."));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The drawing's spelling of each named layer, checked as sources. Duplicates collapse.
|
||||
fn resolve_sources(d: &Drawing, cmd: &str, names: &[String], delete: bool) -> Result<Vec<String>> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for n in names {
|
||||
let l = d.layer(n.trim()).ok_or_else(|| bad(cmd, format!("no layer `{}`", n.trim())))?;
|
||||
if let Some(why) = source_refusal(d, &l.name, delete) {
|
||||
return Err(bad(cmd, why));
|
||||
}
|
||||
if !out.iter().any(|o| o.eq_ignore_ascii_case(&l.name)) {
|
||||
out.push(l.name.clone());
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, if delete { "`names` is required" } else { "`from` is required" }));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every entity store whose objects belong to this drawing: model space, layouts and the block
|
||||
/// definitions (an attached xref's blocks are left alone; they come from its file).
|
||||
/// `f` gets each store with objects on the `set` layers and returns how many it changed.
|
||||
fn for_each_store(d: &mut Drawing, set: &HashSet<String>, mut f: impl FnMut(&mut EntityStore) -> usize) -> usize {
|
||||
let mut n = f(&mut d.model);
|
||||
for layout in &mut d.layouts {
|
||||
n += f(&mut layout.entities);
|
||||
}
|
||||
for block in d.blocks.values_mut() {
|
||||
// Copy-on-write: only definitions that use the layers are touched.
|
||||
if block.xref_path.is_none() && on_layers(&block.entities, set).next().is_some() {
|
||||
n += f(&mut std::sync::Arc::make_mut(block).entities);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn on_layers<'a>(store: &'a EntityStore, set: &'a HashSet<String>) -> impl Iterator<Item = Handle> + 'a {
|
||||
store.iter().filter(|e| set.contains(&e.common.layer.to_ascii_lowercase())).map(|e| e.handle)
|
||||
}
|
||||
|
||||
/// Drop the removed layers from every layout viewport's frozen list and colour overrides.
|
||||
fn forget_in_viewports(d: &mut Drawing, set: &HashSet<String>) {
|
||||
for layout in &mut d.layouts {
|
||||
let hs: Vec<Handle> = layout
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) => {
|
||||
v.frozen_layers.iter().any(|l| set.contains(&l.to_ascii_lowercase()))
|
||||
|| v.layer_colors.iter().any(|(l, _)| set.contains(&l.to_ascii_lowercase()))
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.map(|e| e.handle)
|
||||
.collect();
|
||||
for h in hs {
|
||||
layout.entities.modify(h, |e| {
|
||||
if let EntityKind::Viewport(v) = &mut e.kind {
|
||||
v.frozen_layers.retain(|l| !set.contains(&l.to_ascii_lowercase()));
|
||||
v.layer_colors.retain(|(l, _)| !set.contains(&l.to_ascii_lowercase()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_set(names: &[String]) -> HashSet<String> {
|
||||
names.iter().map(|n| n.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// `laymrg {from: [names] | name, to}`: move every object on the `from` layers to `to`, then
|
||||
/// delete the `from` layers. Merging away the current layer makes the target current.
|
||||
pub(super) fn run_laymrg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = str_param(p, "to").map(str::trim).ok_or_else(|| bad("laymrg", "`to` is required"))?;
|
||||
let d = s.doc()?;
|
||||
let target = d.layer(to).ok_or_else(|| bad("laymrg", format!("no layer `{to}`")))?.name.clone();
|
||||
if xref_layer(&target) {
|
||||
return Err(bad("laymrg", format!("cannot merge into xref-dependent layer \"{target}\"")));
|
||||
}
|
||||
let sources = resolve_sources(d, "laymrg", &names_param(p, "from"), false)?;
|
||||
if sources.iter().any(|n| n.eq_ignore_ascii_case(&target)) {
|
||||
return Err(bad("laymrg", format!("cannot merge layer \"{target}\" into itself")));
|
||||
}
|
||||
let set = lower_set(&sources);
|
||||
let d = s.doc_mut()?;
|
||||
let moved = for_each_store(d, &set, |store| {
|
||||
let hs: Vec<Handle> = on_layers(store, &set).collect();
|
||||
for h in &hs {
|
||||
store.modify(*h, |e| e.common.layer = target.clone());
|
||||
}
|
||||
hs.len()
|
||||
});
|
||||
forget_in_viewports(d, &set);
|
||||
d.layers.retain(|l| !set.contains(&l.name.to_ascii_lowercase()));
|
||||
let mut msg = format!("Merged {} into \"{target}\": {} moved.", quoted(&sources), plural(moved, "object"));
|
||||
if set.contains(&d.header.str("CLAYER", "0").to_ascii_lowercase()) {
|
||||
if let Some(l) = d.layer_mut(&target) {
|
||||
l.frozen = false;
|
||||
}
|
||||
d.header.set_str("CLAYER", &target);
|
||||
msg.push_str(&format!("\nLayer \"{target}\" is now current."));
|
||||
}
|
||||
Ok(json!({ "merged": sources, "into": target, "moved": moved, "message": msg }))
|
||||
}
|
||||
|
||||
/// `laydel {names: [...] | name}`: delete the layers and every object on them.
|
||||
pub(super) fn run_laydel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut names = names_param(p, "names");
|
||||
names.extend(names_param(p, "name"));
|
||||
let sources = resolve_sources(s.doc()?, "laydel", &names, true)?;
|
||||
let set = lower_set(&sources);
|
||||
let d = s.doc_mut()?;
|
||||
let erased = for_each_store(d, &set, |store| {
|
||||
let hs: Vec<Handle> = on_layers(store, &set).collect();
|
||||
for h in &hs {
|
||||
store.remove(*h);
|
||||
}
|
||||
hs.len()
|
||||
});
|
||||
forget_in_viewports(d, &set);
|
||||
d.layers.retain(|l| !set.contains(&l.name.to_ascii_lowercase()));
|
||||
s.set_selection(Vec::new());
|
||||
let msg = format!("Deleted {} and {}.", quoted(&sources), plural(erased, "object"));
|
||||
Ok(json!({ "deleted": sources, "erased": erased, "message": msg }))
|
||||
}
|
||||
|
||||
fn quoted(names: &[String]) -> String {
|
||||
let list = names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ");
|
||||
if names.len() == 1 { format!("layer {list}") } else { format!("layers {list}") }
|
||||
}
|
||||
|
||||
// ---------------- typed: LAYMRG / LAYDEL ----------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Stage {
|
||||
/// Picking the layers to merge or delete.
|
||||
Source,
|
||||
SourceName,
|
||||
Target,
|
||||
TargetName,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
/// Keyword-only prompt.
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
/// A layer name (may contain spaces); numbers are names too.
|
||||
const NAME: Accept = Accept { number: true, ..Accept::TEXT };
|
||||
|
||||
/// The LAYMRG and LAYDEL prompt sequence.
|
||||
pub(super) struct LayerRemove {
|
||||
delete: bool,
|
||||
stage: Stage,
|
||||
sources: Vec<String>,
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
impl LayerRemove {
|
||||
pub(super) fn merge() -> Self {
|
||||
LayerRemove { delete: false, stage: Stage::Source, sources: Vec::new(), target: None }
|
||||
}
|
||||
pub(super) fn delete() -> Self {
|
||||
LayerRemove { delete: true, ..LayerRemove::merge() }
|
||||
}
|
||||
|
||||
fn id(&self) -> &'static str {
|
||||
if self.delete { "laydel" } else { "laymrg" }
|
||||
}
|
||||
|
||||
fn add_source(&mut self, s: &mut Session, name: &str) {
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let Some(l) = d.layer(name.trim()) else {
|
||||
s.echo(format!("Layer \"{}\" not found.", name.trim()));
|
||||
return;
|
||||
};
|
||||
let name = l.name.clone();
|
||||
if let Some(why) = source_refusal(d, &name, self.delete) {
|
||||
s.echo(why);
|
||||
return;
|
||||
}
|
||||
if !self.sources.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
self.sources.push(name);
|
||||
}
|
||||
s.echo(format!("Selected layers: {}.", self.sources.join(", ")));
|
||||
}
|
||||
|
||||
fn set_target(&mut self, s: &mut Session, name: &str) {
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let Some(l) = d.layer(name.trim()) else {
|
||||
s.echo(format!("Layer \"{}\" not found.", name.trim()));
|
||||
return;
|
||||
};
|
||||
let name = l.name.clone();
|
||||
if xref_layer(&name) {
|
||||
s.echo(format!("Cannot merge into xref-dependent layer \"{name}\"."));
|
||||
} else if self.sources.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
s.echo(format!("Cannot merge layer \"{name}\" into itself."));
|
||||
} else {
|
||||
self.target = Some(name);
|
||||
self.confirm(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the warning and ask.
|
||||
fn confirm(&mut self, s: &mut Session) {
|
||||
s.echo("******** WARNING ********");
|
||||
match &self.target {
|
||||
Some(t) if !self.delete => s.echo(format!("You are about to merge {} into layer \"{t}\".", quoted(&self.sources))),
|
||||
_ => s.echo(format!("You are about to delete {} and every object on it from this drawing.", quoted(&self.sources))),
|
||||
}
|
||||
s.echo("Do you wish to continue?");
|
||||
self.stage = Stage::Confirm;
|
||||
}
|
||||
|
||||
fn layers_of(s: &Session, hs: &[Handle]) -> Vec<String> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for l in hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.common.layer.clone()) {
|
||||
if !out.iter().any(|o| o.eq_ignore_ascii_case(&l)) {
|
||||
out.push(l);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the JSON body directly: the running command records the single undo step.
|
||||
fn apply(&self, s: &mut Session) -> Result<Step> {
|
||||
let spec = find_command(self.id()).ok_or_else(|| EngineError::UnknownCommand(self.id().into()))?;
|
||||
let p = match &self.target {
|
||||
Some(t) if !self.delete => json!({ "from": self.sources, "to": t }),
|
||||
_ => json!({ "names": self.sources }),
|
||||
};
|
||||
let r = (spec.run)(s, &p)?;
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
for l in m.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LayerRemove {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.delete { "LAYDEL" } else { "LAYMRG" }
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Source => {
|
||||
let msg = if self.delete { "Select object on layer to delete" } else { "Select object on layer to merge" };
|
||||
let kw: &[&str] = if self.sources.is_empty() { &["Name"] } else { &["Name", "Undo"] };
|
||||
Prompt::new(msg, Accept::SELECT).kw(kw)
|
||||
}
|
||||
Stage::SourceName => Prompt::new("Enter layer name", NAME),
|
||||
Stage::Target => Prompt::new("Select object on target layer", Accept::SELECT).kw(&["Name"]),
|
||||
Stage::TargetName => Prompt::new("Enter target layer name", NAME),
|
||||
// The question itself is echoed with the warning: "[Yes/No] <No>:" follows it.
|
||||
Stage::Confirm => Prompt::new("", KW).kw(&["Yes", "No"]).default("No"),
|
||||
}
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
// At "Select object" prompts the selection layer hands typed keywords over as text.
|
||||
let i = match i {
|
||||
Input::Text(t) if matches!(self.stage, Stage::Source | Stage::Target) => match self.prompt(s).match_keyword(&t) {
|
||||
Some(k) => Input::Keyword(k),
|
||||
None => Input::Text(t),
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
match (self.stage, i) {
|
||||
(_, Input::Cancel) => return Ok(Step::Cancel),
|
||||
(Stage::Source, Input::Pick(hs)) => {
|
||||
for l in Self::layers_of(s, &hs) {
|
||||
self.add_source(s, &l);
|
||||
}
|
||||
}
|
||||
(Stage::Source, Input::Keyword(k)) if k == "Name" => self.stage = Stage::SourceName,
|
||||
(Stage::Source, Input::Keyword(k)) if k == "Undo" => {
|
||||
self.sources.pop();
|
||||
s.echo(format!("Selected layers: {}.", self.sources.join(", ")));
|
||||
}
|
||||
(Stage::Source, Input::Enter) => {
|
||||
if self.sources.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
if self.delete {
|
||||
self.confirm(s);
|
||||
} else {
|
||||
self.stage = Stage::Target;
|
||||
}
|
||||
}
|
||||
(Stage::SourceName, Input::Text(t) | Input::Keyword(t)) => {
|
||||
// Layer names never contain commas: a list adds each one.
|
||||
for n in t.split(',').map(str::trim).filter(|n| !n.is_empty()).take(1000) {
|
||||
self.add_source(s, n);
|
||||
}
|
||||
self.stage = Stage::Source;
|
||||
}
|
||||
(Stage::SourceName, Input::Enter) => self.stage = Stage::Source,
|
||||
(Stage::Target, Input::Pick(hs)) => {
|
||||
if let Some(l) = Self::layers_of(s, &hs).first() {
|
||||
self.set_target(s, l);
|
||||
}
|
||||
}
|
||||
(Stage::Target, Input::Keyword(k)) if k == "Name" => self.stage = Stage::TargetName,
|
||||
(Stage::Target, Input::Enter) => return Ok(Step::Done),
|
||||
(Stage::TargetName, Input::Text(t) | Input::Keyword(t)) => {
|
||||
self.stage = Stage::Target;
|
||||
self.set_target(s, &t);
|
||||
}
|
||||
(Stage::TargetName, Input::Enter) => self.stage = Stage::Target,
|
||||
(Stage::Confirm, Input::Keyword(k)) if k == "Yes" => return self.apply(s),
|
||||
(Stage::Confirm, Input::Keyword(_) | Input::Enter) => return Ok(Step::Done),
|
||||
(Stage::Confirm, _) => s.echo("Enter Yes or No."),
|
||||
(_, Input::Text(_) | Input::Keyword(_)) => s.echo("*Invalid selection*"),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! -LAYER: the command-line form of LAYER. An option loop that ends on Enter; every option runs
|
||||
//! the `layer.*` commands, so validation matches the JSON form and the whole session is one undo
|
||||
//! step.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::Lineweight;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::find_command;
|
||||
use super::super::qselect::wildcard;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const OPTIONS: &[&str] = &[
|
||||
"?",
|
||||
"Make",
|
||||
"Set",
|
||||
"New",
|
||||
"Rename",
|
||||
"ON",
|
||||
"OFF",
|
||||
"Color",
|
||||
"Ltype",
|
||||
"LWeight",
|
||||
"TRansparency",
|
||||
"MATerial",
|
||||
"Plot",
|
||||
"Freeze",
|
||||
"Thaw",
|
||||
"LOck",
|
||||
"Unlock",
|
||||
"stAte",
|
||||
"Description",
|
||||
"rEconcile",
|
||||
];
|
||||
|
||||
/// Keyword-only: spaces separate inputs.
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
/// A name or value: Space ends the input, as it does in AutoCAD scripts.
|
||||
const NAME: Accept = Accept { number: true, ..Accept::TEXT };
|
||||
|
||||
/// A per-layer change applied to a list of layer names.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Change {
|
||||
On,
|
||||
Off,
|
||||
Freeze,
|
||||
Thaw,
|
||||
Lock,
|
||||
Unlock,
|
||||
Color(String),
|
||||
Ltype(String),
|
||||
Lweight(f64),
|
||||
Transparency(u64),
|
||||
Plot(bool),
|
||||
Description(String),
|
||||
}
|
||||
|
||||
impl Change {
|
||||
fn params(&self) -> Value {
|
||||
match self {
|
||||
Change::On => json!({ "on": true }),
|
||||
Change::Off => json!({ "on": false }),
|
||||
Change::Freeze => json!({ "frozen": true }),
|
||||
Change::Thaw => json!({ "frozen": false }),
|
||||
Change::Lock => json!({ "locked": true }),
|
||||
Change::Unlock => json!({ "locked": false }),
|
||||
Change::Color(c) => json!({ "color": c }),
|
||||
Change::Ltype(l) => json!({ "linetype": l }),
|
||||
Change::Lweight(mm) => json!({ "lineweight": mm }),
|
||||
Change::Transparency(t) => json!({ "transparency": t }),
|
||||
Change::Plot(p) => json!({ "plot": p }),
|
||||
Change::Description(d) => json!({ "description": d }),
|
||||
}
|
||||
}
|
||||
/// The layer-list prompt for this change; `true` when Enter means the current layer.
|
||||
fn prompt(&self) -> (String, bool) {
|
||||
match self {
|
||||
Change::On => ("Enter name list of layer(s) to turn on".into(), false),
|
||||
Change::Off => ("Enter name list of layer(s) to turn off".into(), false),
|
||||
Change::Freeze => ("Enter name list of layer(s) to freeze".into(), false),
|
||||
Change::Thaw => ("Enter name list of layer(s) to thaw".into(), false),
|
||||
Change::Lock => ("Enter name list of layer(s) to lock".into(), false),
|
||||
Change::Unlock => ("Enter name list of layer(s) to unlock".into(), false),
|
||||
Change::Color(c) => (format!("Enter name list of layer(s) for color {c}"), true),
|
||||
Change::Ltype(l) => (format!("Enter name list of layer(s) for linetype \"{l}\""), true),
|
||||
Change::Lweight(mm) => (format!("Enter name list of layer(s) for lineweight {mm:.2}mm"), true),
|
||||
Change::Transparency(t) => (format!("Enter name list of layer(s) for transparency {t}%"), true),
|
||||
Change::Plot(_) => ("Enter layer name(s) for this plot preference".into(), true),
|
||||
Change::Description(_) => ("Enter name list of layer(s) to apply description".into(), true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Option,
|
||||
List,
|
||||
Make,
|
||||
Set,
|
||||
New,
|
||||
RenameFrom,
|
||||
RenameTo(String),
|
||||
Color,
|
||||
TrueColor,
|
||||
Ltype,
|
||||
Lweight,
|
||||
Transparency,
|
||||
Plot,
|
||||
Description,
|
||||
Layers(Change),
|
||||
/// Turning the current layer off asks first.
|
||||
ConfirmOff(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LayerM {
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
fn current_layer(s: &Session) -> String {
|
||||
s.doc().map(|d| d.header.str("CLAYER", "0")).unwrap_or_else(|_| "0".into())
|
||||
}
|
||||
|
||||
/// Run a layer command's body directly: the running command records the single undo step.
|
||||
fn call(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.run)(s, p)
|
||||
}
|
||||
|
||||
fn message(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn retry(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
/// Layers matching a comma-separated name list (wildcards allowed), in drawing order.
|
||||
fn matching(s: &mut Session, list: &str) -> Vec<String> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
for pat in list.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000) {
|
||||
let before = out.len();
|
||||
for l in &d.layers {
|
||||
if wildcard(pat, &l.name) && !out.iter().any(|n| n.eq_ignore_ascii_case(&l.name)) {
|
||||
out.push(l.name.clone());
|
||||
}
|
||||
}
|
||||
if out.len() == before && !d.layers.iter().any(|l| wildcard(pat, &l.name)) {
|
||||
missing.push(pat.to_string());
|
||||
}
|
||||
}
|
||||
for m in missing {
|
||||
s.echo(format!("Cannot find layer \"{m}\"."));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Nearest standard lineweight to `mm`.
|
||||
fn snap_lineweight(mm: f64) -> f64 {
|
||||
let want = mm * 100.0;
|
||||
let best = Lineweight::STANDARD.iter().copied().min_by(|a, b| (f64::from(*a) - want).abs().total_cmp(&(f64::from(*b) - want).abs())).unwrap_or(0);
|
||||
f64::from(best) / 100.0
|
||||
}
|
||||
|
||||
impl LayerM {
|
||||
fn option(&mut self, s: &mut Session, k: &str) -> Result<Step> {
|
||||
self.stage = match k {
|
||||
"?" => Stage::List,
|
||||
"Make" => Stage::Make,
|
||||
"Set" => Stage::Set,
|
||||
"New" => Stage::New,
|
||||
"Rename" => Stage::RenameFrom,
|
||||
"ON" => Stage::Layers(Change::On),
|
||||
"OFF" => Stage::Layers(Change::Off),
|
||||
"Color" => Stage::Color,
|
||||
"Ltype" => Stage::Ltype,
|
||||
"LWeight" => Stage::Lweight,
|
||||
"TRansparency" => Stage::Transparency,
|
||||
"Plot" => Stage::Plot,
|
||||
"Freeze" => Stage::Layers(Change::Freeze),
|
||||
"Thaw" => Stage::Layers(Change::Thaw),
|
||||
"LOck" => Stage::Layers(Change::Lock),
|
||||
"Unlock" => Stage::Layers(Change::Unlock),
|
||||
"Description" => Stage::Description,
|
||||
other => {
|
||||
// MATerial, stAte, rEconcile: report and stay at the option prompt.
|
||||
s.echo(format!("-LAYER {other} is not available yet."));
|
||||
Stage::Option
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn list(&mut self, s: &mut Session, pat: &str) {
|
||||
let names = matching(s, if pat.trim().is_empty() { "*" } else { pat });
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let cur = d.header.str("CLAYER", "0");
|
||||
let mut lines = vec![format!("{:<24} {:<26} {:<10} {:<14} {}", "Layer name", "State", "Color", "Linetype", "Lineweight")];
|
||||
for l in names.iter().filter_map(|n| d.layer(n)) {
|
||||
let state = format!(
|
||||
"{} {} {}{}",
|
||||
if l.on { "On" } else { "Off" },
|
||||
if l.frozen { "Frozen" } else { "Thawed" },
|
||||
if l.locked { "Locked" } else { "Unlocked" },
|
||||
if l.plot { "" } else { " No plot" }
|
||||
);
|
||||
lines.push(format!("{:<24} {:<26} {:<10} {:<14} {}", l.name, state, l.color.name(), l.linetype, l.lineweight.name()));
|
||||
}
|
||||
lines.push(format!("Current layer: \"{cur}\""));
|
||||
for l in lines {
|
||||
s.echo(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `change` to the named layers through `layer.set`; refusals are reported per layer.
|
||||
fn apply(&mut self, s: &mut Session, change: &Change, names: &[String]) {
|
||||
for n in names {
|
||||
let mut p = change.params();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("name".into(), json!(n));
|
||||
}
|
||||
if let Err(e) = call(s, "layer.set", &p) {
|
||||
s.echo(format!("Layer \"{n}\": {}", message(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(&mut self, s: &mut Session, change: Change, text: Option<&str>) -> Result<Step> {
|
||||
let (_, enter_is_current) = change.prompt();
|
||||
let names = match text {
|
||||
Some(t) => matching(s, t),
|
||||
None if enter_is_current => vec![current_layer(s)],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let cur = current_layer(s);
|
||||
if matches!(change, Change::Off) && names.iter().any(|n| n.eq_ignore_ascii_case(&cur)) {
|
||||
self.stage = Stage::ConfirmOff(names);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if matches!(change, Change::Freeze) && names.iter().any(|n| n.eq_ignore_ascii_case(&cur)) {
|
||||
s.echo(format!("Cannot freeze layer \"{cur}\". It is the CURRENT layer."));
|
||||
}
|
||||
let names: Vec<String> = names.into_iter().filter(|n| !(matches!(change, Change::Freeze) && n.eq_ignore_ascii_case(&cur))).collect();
|
||||
self.apply(s, &change, &names);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// One input at a sub-prompt. Returns to the option prompt unless a value must be re-entered.
|
||||
fn sub(&mut self, s: &mut Session, stage: Stage, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
_ => None,
|
||||
};
|
||||
let back = |m: &mut LayerM, st: Stage| {
|
||||
m.stage = st;
|
||||
Ok(Step::Continue)
|
||||
};
|
||||
match stage {
|
||||
Stage::Option => Ok(Step::Continue),
|
||||
Stage::List => {
|
||||
self.list(s, text.as_deref().unwrap_or("*"));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Make => {
|
||||
let Some(n) = text else { return Ok(Step::Continue) };
|
||||
let exists = s.doc()?.layer(&n).is_some();
|
||||
let r = if exists {
|
||||
call(s, "layer.current", &json!({ "name": n }))
|
||||
} else {
|
||||
call(s, "layer.new", &json!({ "name": n, "current": true }))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
s.echo(message(&e));
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Set => {
|
||||
let Some(n) = text else { return Ok(Step::Continue) };
|
||||
if s.doc()?.layer(&n).is_none() {
|
||||
s.echo(format!("Cannot find layer \"{n}\"."));
|
||||
} else if let Err(e) = call(s, "layer.current", &json!({ "name": n })) {
|
||||
s.echo(message(&e));
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::New => {
|
||||
for n in text.as_deref().unwrap_or("").split(',').map(str::trim).filter(|n| !n.is_empty()).take(1000) {
|
||||
if let Err(e) = call(s, "layer.new", &json!({ "name": n })) {
|
||||
s.echo(message(&e));
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::RenameFrom => match text {
|
||||
None => Ok(Step::Continue),
|
||||
Some(n) => match s.doc()?.layer(&n).map(|l| l.name.clone()) {
|
||||
Some(old) => back(self, Stage::RenameTo(old)),
|
||||
None => {
|
||||
s.echo(format!("Cannot find layer \"{n}\"."));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
},
|
||||
Stage::RenameTo(old) => {
|
||||
if let Some(n) = text
|
||||
&& let Err(e) = call(s, "layer.set", &json!({ "name": old, "newName": n }))
|
||||
{
|
||||
s.echo(message(&e));
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Color => match (&i, text) {
|
||||
(Input::Keyword(k), _) if k == "Truecolor" => back(self, Stage::TrueColor),
|
||||
(Input::Keyword(_), _) => {
|
||||
s.echo("Color books are not available yet.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, None) => Ok(Step::Continue),
|
||||
(_, Some(t)) => match Color::parse(&t) {
|
||||
Some(Color::Index(c)) => back(self, Stage::Layers(Change::Color(c.to_string()))),
|
||||
Some(Color::True(rgb)) => back(self, Stage::Layers(Change::Color(format!("{},{},{}", rgb.0, rgb.1, rgb.2)))),
|
||||
_ => {
|
||||
self.stage = Stage::Color;
|
||||
Err(retry("Enter a color name or a number from 1 to 255."))
|
||||
}
|
||||
},
|
||||
},
|
||||
Stage::TrueColor => match text.as_deref().map(Color::parse) {
|
||||
None => Ok(Step::Continue),
|
||||
Some(Some(Color::True(rgb))) => back(self, Stage::Layers(Change::Color(format!("{},{},{}", rgb.0, rgb.1, rgb.2)))),
|
||||
Some(_) => {
|
||||
self.stage = Stage::TrueColor;
|
||||
Err(retry("Enter three values from 0 to 255, separated by commas."))
|
||||
}
|
||||
},
|
||||
Stage::Ltype => match (&i, text) {
|
||||
(Input::Keyword(_), _) => {
|
||||
let names: Vec<String> = s.doc()?.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
s.echo(format!("Loaded linetypes: {}", names.join(", ")));
|
||||
back(self, Stage::Ltype)
|
||||
}
|
||||
(_, None) => back(self, Stage::Layers(Change::Ltype("Continuous".into()))),
|
||||
(_, Some(t)) => {
|
||||
let found =
|
||||
s.doc()?.linetype(&t).map(|l| l.name.clone()).filter(|n| !["bylayer", "byblock"].contains(&n.to_ascii_lowercase().as_str()));
|
||||
match found {
|
||||
Some(n) => back(self, Stage::Layers(Change::Ltype(n))),
|
||||
None => {
|
||||
s.echo(format!("Linetype \"{t}\" is not loaded. Load it with the LINETYPE command."));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Stage::Lweight => match text {
|
||||
None => Ok(Step::Continue),
|
||||
Some(t) => match t.trim_end_matches("mm").trim().parse::<f64>().ok().filter(|v| v.is_finite() && (0.0..=2.11).contains(v)) {
|
||||
Some(mm) => back(self, Stage::Layers(Change::Lweight(snap_lineweight(mm)))),
|
||||
None => {
|
||||
self.stage = Stage::Lweight;
|
||||
Err(retry("Enter a lineweight from 0.0mm to 2.11mm."))
|
||||
}
|
||||
},
|
||||
},
|
||||
Stage::Transparency => {
|
||||
let v = match text {
|
||||
None => Some(0),
|
||||
Some(t) => t.parse::<u64>().ok().filter(|v| *v <= 90),
|
||||
};
|
||||
match v {
|
||||
Some(v) => back(self, Stage::Layers(Change::Transparency(v))),
|
||||
None => {
|
||||
self.stage = Stage::Transparency;
|
||||
Err(retry("Enter a transparency value from 0 to 90."))
|
||||
}
|
||||
}
|
||||
}
|
||||
Stage::Plot => match &i {
|
||||
Input::Keyword(k) => back(self, Stage::Layers(Change::Plot(k != "No"))),
|
||||
Input::Enter => back(self, Stage::Layers(Change::Plot(true))),
|
||||
_ => {
|
||||
self.stage = Stage::Plot;
|
||||
Err(retry("Enter Plot or No."))
|
||||
}
|
||||
},
|
||||
Stage::Description => back(self, Stage::Layers(Change::Description(text.unwrap_or_default()))),
|
||||
Stage::Layers(change) => self.layers(s, change, text.as_deref()),
|
||||
Stage::ConfirmOff(names) => {
|
||||
let cur = current_layer(s);
|
||||
let yes = matches!(&i, Input::Keyword(k) if k == "Yes");
|
||||
let names: Vec<String> = names.into_iter().filter(|n| yes || !n.eq_ignore_ascii_case(&cur)).collect();
|
||||
self.apply(s, &Change::Off, &names);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LayerM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-LAYER"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let cur = current_layer(s);
|
||||
s.echo(format!("Current layer: \"{cur}\""));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = current_layer(s);
|
||||
match &self.stage {
|
||||
Stage::Option => Prompt::new("Enter an option", KW).kw(OPTIONS),
|
||||
Stage::List => Prompt::new("Enter layer name(s) to list", NAME).default("*"),
|
||||
Stage::Make => Prompt::new("Enter name for new layer (becomes the current layer)", NAME).default(cur),
|
||||
Stage::Set => Prompt::new("Enter layer name to make current", NAME),
|
||||
Stage::New => Prompt::new("Enter name list for new layer(s)", NAME),
|
||||
Stage::RenameFrom => Prompt::new("Enter old layer name", NAME),
|
||||
Stage::RenameTo(_) => Prompt::new("Enter new layer name", NAME),
|
||||
Stage::Color => Prompt::new("Enter color name or number (1-255)", NAME).kw(&["Truecolor", "COlorbook"]),
|
||||
Stage::TrueColor => Prompt::new("Red, Green, Blue", NAME),
|
||||
Stage::Ltype => Prompt::new("Enter a loaded linetype name", NAME).kw(&["?"]).default("Continuous"),
|
||||
Stage::Lweight => Prompt::new("Enter lineweight (0.0mm - 2.11mm)", NAME),
|
||||
Stage::Transparency => Prompt::new("Enter transparency value (0-90)", NAME).default("0"),
|
||||
Stage::Plot => Prompt::new("Enter a plotting preference", KW).kw(&["Plot", "No"]).default("Plot"),
|
||||
Stage::Description => Prompt::new("Enter layer description", Accept::TEXT),
|
||||
Stage::Layers(c) => {
|
||||
let (msg, enter_is_current) = c.prompt();
|
||||
let p = Prompt::new(msg, NAME);
|
||||
if enter_is_current { p.default(cur) } else { p }
|
||||
}
|
||||
Stage::ConfirmOff(_) => Prompt::new(format!("Really want layer \"{cur}\" (the CURRENT layer) off?"), KW).kw(&["Yes", "No"]).default("N"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (&self.stage, &i) {
|
||||
(_, Input::Cancel) => Ok(Step::Cancel),
|
||||
(Stage::Option, Input::Enter) => Ok(Step::Done),
|
||||
(Stage::Option, Input::Keyword(k)) => {
|
||||
let k = k.clone();
|
||||
self.option(s, &k)
|
||||
}
|
||||
(Stage::Option, _) => Err(retry("Invalid option keyword.")),
|
||||
_ => {
|
||||
let stage = std::mem::take(&mut self.stage);
|
||||
self.sub(s, stage, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,15 @@ use super::file::{base64_encode, io};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod plotstyle;
|
||||
mod prompts;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("layout", "Layout", run_layout)
|
||||
.alias(&["lo"])
|
||||
.params("{option: new|copy|delete|rename|set|list, name?, to?}"),
|
||||
.params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)")
|
||||
.interactive(|_| Ok(Box::new(prompts::LayoutM::default()))),
|
||||
CommandSpec::new("layout.new", "New Layout", run_new)
|
||||
.menu(&["Insert", "Layout", "New Layout"])
|
||||
.params("{name?, viewport?: bool (default true)} → {name, viewport}")
|
||||
@@ -53,11 +57,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer], colors?: {layer: color | null}}",
|
||||
),
|
||||
CommandSpec::new("vplayer", "Viewport Layer Freeze", run_viewport_set)
|
||||
.params("{handle?, freeze?: [layer], thaw?: [layer], colors?: {layer: color (\"red\" | 1..255 | \"r,g,b\") | null to clear}}"),
|
||||
.params("{handle?, freeze?: [layer], thaw?: [layer], colors?: {layer: color (\"red\" | 1..255 | \"r,g,b\") | null to clear}} (typed: option prompts)")
|
||||
.interactive(|_| Ok(Box::new(vplayer::VplayerM::default()))),
|
||||
CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)
|
||||
.menu(&["File", "Page Setup Manager..."])
|
||||
.params(
|
||||
"{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?}",
|
||||
"{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}",
|
||||
),
|
||||
CommandSpec::new("plot", "Print...", |s, p| run_plot(s, p, "plot"))
|
||||
.menu(&["File", "Print..."])
|
||||
@@ -75,6 +80,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.noundo(),
|
||||
CommandSpec::new("pspace", "Paper Space", run_pspace).alias(&["ps"]).noundo(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(plotstyle::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------- model space through viewports ----------
|
||||
@@ -587,6 +595,9 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// VPLAYER's command-line prompts.
|
||||
mod vplayer;
|
||||
|
||||
fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "viewport.set";
|
||||
let d = s.doc()?;
|
||||
@@ -685,7 +696,20 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "pagesetup";
|
||||
let keys = ["paper", "width", "height", "landscape", "margins", "lineweights", "plotArea", "scale", "scaleToFit", "center", "plotStyleTable"];
|
||||
let keys = [
|
||||
"paper",
|
||||
"width",
|
||||
"height",
|
||||
"landscape",
|
||||
"margins",
|
||||
"lineweights",
|
||||
"plotArea",
|
||||
"scale",
|
||||
"scaleToFit",
|
||||
"center",
|
||||
"plotStyleTable",
|
||||
"displayPlotStyles",
|
||||
];
|
||||
let changes = keys.iter().any(|k| p.get(k).is_some());
|
||||
let name = match (str_param(p, "layout"), s.space()) {
|
||||
(Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?),
|
||||
@@ -701,6 +725,7 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({
|
||||
"layouts": d.layouts.iter().map(|l| json!({"name": l.name, "page": serde_json::to_value(&l.page).unwrap_or(Value::Null)})).collect::<Vec<_>>(),
|
||||
"papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::<Vec<_>>(),
|
||||
"plotStyleTables": cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES,
|
||||
}));
|
||||
};
|
||||
let mut page = s.doc()?.layout(&name).map(|l| l.page.clone()).unwrap_or_default();
|
||||
@@ -756,6 +781,9 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = str_param(p, "plotStyleTable") {
|
||||
page.plot_style_table = v.chars().take(260).collect();
|
||||
}
|
||||
if let Some(v) = p.get("displayPlotStyles").and_then(Value::as_bool) {
|
||||
page.show_plot_styles = v;
|
||||
}
|
||||
if changes {
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) {
|
||||
l.page = page.clone();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Plot styles: PLOTSTYLE (an object's or the current named plot style) and STYLESMANAGER (the
|
||||
//! plot style tables: list, view, create, edit, delete, load and save in our own file format).
|
||||
//!
|
||||
//! Tables the drawing keeps live in `Drawing::plot_style_tables` and are saved with it; they
|
||||
//! shadow built-in tables of the same name. Editing a built-in table stores an edited copy in
|
||||
//! the drawing.
|
||||
|
||||
use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("plotstyle", "Plot Style...", run_plotstyle)
|
||||
.menu(&["Format", "Plot Style..."])
|
||||
.params(
|
||||
"{name?: \"ByLayer\"|\"ByBlock\"|style name, handles?|handle? (default: the selection; nothing selected sets the current plot style for new objects, CPLOTSTYLE)} (no name: reports the current style and the named styles)",
|
||||
),
|
||||
CommandSpec::new("stylesmanager", "Plot Style Manager...", run_stylesmanager)
|
||||
.menu(&["File", "Plot Styles..."])
|
||||
.params(
|
||||
"{action?: list (default)|get|new|set|delete|load|save, name?: table (\"x.ctb\"/\"x.stb\"), kind?: colorDependent|named (new), from?: table to copy (new), description?, style?: {name, color?: \"#rrggbb\"|null, grayscale?, screening?: 0-100, linetype?: name|null, lineweight?: mm|null, end?, join?, fill?, description?} (set), removeStyle?: name (set, named tables), path? (load/save; save without one returns `text`), text? (load)}",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- PLOTSTYLE ----------
|
||||
|
||||
/// The style names an object can be given: those of the named tables the layouts use, else of
|
||||
/// every named table the drawing can use.
|
||||
fn named_styles(d: &Drawing) -> Vec<String> {
|
||||
let mut used: Vec<PlotStyleTable> =
|
||||
d.layouts.iter().filter_map(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)).filter(|t| !t.is_color_dependent()).collect();
|
||||
if used.is_empty() {
|
||||
used = cadcraft_doc::plot_style_table_names(d)
|
||||
.iter()
|
||||
.filter_map(|n| cadcraft_doc::plot_style_table(d, n))
|
||||
.filter(|t| !t.is_color_dependent())
|
||||
.collect();
|
||||
}
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for s in used.iter().flat_map(|t| t.styles.iter()) {
|
||||
if !out.iter().any(|n| n.eq_ignore_ascii_case(&s.name)) {
|
||||
out.push(s.name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plot style property value: `ByLayer`, `ByBlock` or a style name (at most 255 characters).
|
||||
fn style_value(cmd: &str, v: &str) -> Result<String> {
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
return Err(bad(cmd, "the plot style name is empty"));
|
||||
}
|
||||
Ok(if v.eq_ignore_ascii_case("bylayer") {
|
||||
"ByLayer".into()
|
||||
} else if v.eq_ignore_ascii_case("byblock") {
|
||||
"ByBlock".into()
|
||||
} else {
|
||||
v.chars().take(255).collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn run_plotstyle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plotstyle";
|
||||
let Some(name) = p.get("name") else {
|
||||
let d = s.doc()?;
|
||||
return Ok(json!({ "current": d.header.str("CPLOTSTYLE", "ByLayer"), "styles": named_styles(d) }));
|
||||
};
|
||||
let name = style_value(cmd, name.as_str().ok_or_else(|| bad(cmd, "`name` must be a plot style name"))?)?;
|
||||
let handles = targets(s, p)?;
|
||||
if handles.is_empty() {
|
||||
s.doc_mut()?.header.set_str("CPLOTSTYLE", &name);
|
||||
return Ok(json!({ "current": name }));
|
||||
}
|
||||
let mut changed = 0usize;
|
||||
for h in &handles {
|
||||
if super::super::curves::is_locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
if d.entity(*h).is_none() {
|
||||
return Err(bad(cmd, format!("no object {}", h.hex())));
|
||||
}
|
||||
d.modify_entity(*h, |e| e.common.plot_style = name.clone())?;
|
||||
changed += 1;
|
||||
}
|
||||
Ok(json!({ "changed": changed, "plotStyle": name }))
|
||||
}
|
||||
|
||||
// ---------- STYLESMANAGER ----------
|
||||
|
||||
fn table_summary(d: &Drawing, t: &PlotStyleTable) -> Value {
|
||||
let in_drawing = d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&t.name));
|
||||
json!({
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"kind": if t.is_color_dependent() { "colorDependent" } else { "named" },
|
||||
"styles": t.styles.len(),
|
||||
"inDrawing": in_drawing,
|
||||
"builtin": PlotStyleTable::builtin(&t.name).is_some() && !in_drawing,
|
||||
})
|
||||
}
|
||||
|
||||
fn table_name(cmd: &str, p: &Value) -> Result<String> {
|
||||
let n = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` (the table) is required"))?;
|
||||
Ok(n.chars().take(255).collect())
|
||||
}
|
||||
|
||||
/// The table `name` as the drawing sees it.
|
||||
fn find_table(cmd: &str, d: &Drawing, name: &str) -> Result<PlotStyleTable> {
|
||||
cadcraft_doc::plot_style_table(d, name).ok_or_else(|| bad(cmd, format!("no plot style table `{name}`")))
|
||||
}
|
||||
|
||||
/// Store `t` in the drawing, replacing a table of the same name.
|
||||
fn store(d: &mut Drawing, t: PlotStyleTable) {
|
||||
match d.plot_style_tables.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&t.name)) {
|
||||
Some(x) => *x = t,
|
||||
None => d.plot_style_tables.push(t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the fields present in `v` to style `st`.
|
||||
fn edit_style(cmd: &str, st: &mut PlotStyle, v: &Value) -> Result<()> {
|
||||
let opt_str = |k: &str| -> Result<Option<Option<String>>> {
|
||||
match v.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(s)) if s.trim().is_empty() || s.eq_ignore_ascii_case("object") => Ok(Some(None)),
|
||||
Some(Value::String(s)) => Ok(Some(Some(s.trim().chars().take(255).collect()))),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a string or null"))),
|
||||
}
|
||||
};
|
||||
if let Some(c) = opt_str("color")? {
|
||||
st.color = match c {
|
||||
None => None,
|
||||
Some(h) => {
|
||||
Some(cadcraft_color::Rgb::parse_hex(h.trim_start_matches('#')).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or null"))?)
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(lt) = opt_str("linetype")? {
|
||||
st.linetype = lt;
|
||||
}
|
||||
if let Some(d) = opt_str("description")? {
|
||||
st.description = d.unwrap_or_default();
|
||||
}
|
||||
if let Some(g) = v.get("grayscale") {
|
||||
st.grayscale = g.as_bool().ok_or_else(|| bad(cmd, "`grayscale` must be true or false"))?;
|
||||
}
|
||||
if let Some(sc) = v.get("screening") {
|
||||
let n = sc.as_f64().filter(|n| n.is_finite() && (0.0..=100.0).contains(n)).ok_or_else(|| bad(cmd, "`screening` must be 0 to 100"))?;
|
||||
st.screening = n.round() as u8;
|
||||
}
|
||||
match v.get("lineweight") {
|
||||
None => {}
|
||||
Some(Value::Null) => st.lineweight = None,
|
||||
Some(Value::String(s)) if s.eq_ignore_ascii_case("object") => st.lineweight = None,
|
||||
Some(w) => {
|
||||
let mm = w
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (0.0..=2.11).contains(n))
|
||||
.ok_or_else(|| bad(cmd, "`lineweight` must be 0 to 2.11 mm or null"))?;
|
||||
st.lineweight = Some((mm * 100.0).round() as u16);
|
||||
}
|
||||
}
|
||||
fn field<T: serde::de::DeserializeOwned>(cmd: &str, v: &Value, k: &str, msg: &str) -> Result<Option<T>> {
|
||||
v.get(k).map(|x| serde_json::from_value(x.clone()).map_err(|_| bad(cmd, msg))).transpose()
|
||||
}
|
||||
if let Some(e) = field(cmd, v, "end", "`end` must be object, butt, square, round or diamond")? {
|
||||
st.end = e;
|
||||
}
|
||||
if let Some(j) = field(cmd, v, "join", "`join` must be object, miter, bevel, round or diamond")? {
|
||||
st.join = j;
|
||||
}
|
||||
if let Some(f) = field(
|
||||
cmd,
|
||||
v,
|
||||
"fill",
|
||||
"`fill` must be object, solid, checkerboard, crosshatch, diamonds, horizontalBars, slantLeft, slantRight, squareDots or verticalBars",
|
||||
)? {
|
||||
st.fill = f;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "stylesmanager";
|
||||
let action = str_param(p, "action").unwrap_or("list").to_ascii_lowercase();
|
||||
match action.as_str() {
|
||||
"list" => {
|
||||
let d = s.doc()?;
|
||||
let tables: Vec<Value> = cadcraft_doc::plot_style_table_names(d)
|
||||
.iter()
|
||||
.filter_map(|n| cadcraft_doc::plot_style_table(d, n))
|
||||
.map(|t| table_summary(d, &t))
|
||||
.collect();
|
||||
Ok(json!({ "tables": tables }))
|
||||
}
|
||||
"get" => {
|
||||
let d = s.doc()?;
|
||||
let t = find_table(cmd, d, &table_name(cmd, p)?)?;
|
||||
let mut v = serde_json::to_value(&t).unwrap_or(Value::Null);
|
||||
if let (Some(o), Value::Object(sum)) = (v.as_object_mut(), table_summary(d, &t)) {
|
||||
o.insert("inDrawing".into(), sum.get("inDrawing").cloned().unwrap_or(Value::Null));
|
||||
o.insert("builtin".into(), sum.get("builtin").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
"new" => {
|
||||
let mut name = table_name(cmd, p)?;
|
||||
let d = s.doc()?;
|
||||
let mut t = match str_param(p, "from") {
|
||||
Some(from) => find_table(cmd, d, from)?,
|
||||
None => {
|
||||
let named = match str_param(p, "kind").map(str::to_ascii_lowercase).as_deref() {
|
||||
None if name.to_ascii_lowercase().ends_with(".stb") => true,
|
||||
None | Some("colordependent" | "ctb") => false,
|
||||
Some("named" | "stb") => true,
|
||||
Some(_) => return Err(bad(cmd, "`kind` must be colorDependent or named")),
|
||||
};
|
||||
if named { PlotStyleTable::named("", "", Vec::new()) } else { PlotStyleTable::color_dependent("", "", |_| None) }
|
||||
}
|
||||
};
|
||||
let ext = if t.is_color_dependent() { ".ctb" } else { ".stb" };
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if !lower.ends_with(ext) {
|
||||
if lower.ends_with(".ctb") || lower.ends_with(".stb") {
|
||||
return Err(bad(cmd, format!("a {} table's name ends in {ext}", if ext == ".ctb" { "colour-dependent" } else { "named" })));
|
||||
}
|
||||
name.push_str(ext);
|
||||
}
|
||||
if d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad(cmd, format!("plot style table `{name}` already exists")));
|
||||
}
|
||||
t.name = name;
|
||||
if let Some(desc) = str_param(p, "description") {
|
||||
t.description = desc.chars().take(255).collect();
|
||||
}
|
||||
let t = t.sanitized();
|
||||
let out = table_summary(d, &t);
|
||||
store(s.doc_mut()?, t);
|
||||
Ok(out)
|
||||
}
|
||||
"set" => {
|
||||
let name = table_name(cmd, p)?;
|
||||
let mut t = find_table(cmd, s.doc()?, &name)?;
|
||||
if let Some(desc) = str_param(p, "description") {
|
||||
t.description = desc.chars().take(255).collect();
|
||||
}
|
||||
if let Some(rm) = str_param(p, "removeStyle") {
|
||||
if t.is_color_dependent() {
|
||||
return Err(bad(cmd, "a colour-dependent table always has 255 styles"));
|
||||
}
|
||||
if rm.trim().eq_ignore_ascii_case(cadcraft_doc::NORMAL_STYLE) {
|
||||
return Err(bad(cmd, "the Normal style can't be removed"));
|
||||
}
|
||||
let before = t.styles.len();
|
||||
t.styles.retain(|x| !x.name.eq_ignore_ascii_case(rm.trim()));
|
||||
if t.styles.len() == before {
|
||||
return Err(bad(cmd, format!("`{name}` has no style `{rm}`")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("style") {
|
||||
let sname = v.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
let sname = sname.ok_or_else(|| bad(cmd, "`style.name` is required"))?;
|
||||
let idx = match t.styles.iter().position(|x| x.name.eq_ignore_ascii_case(sname)) {
|
||||
Some(i) => i,
|
||||
None if t.kind == PlotStyleKind::Named => {
|
||||
if t.styles.len() >= cadcraft_doc::MAX_NAMED_STYLES {
|
||||
return Err(bad(cmd, "the table has too many styles"));
|
||||
}
|
||||
t.styles.push(PlotStyle::object(&sname.chars().take(255).collect::<String>()));
|
||||
t.styles.len() - 1
|
||||
}
|
||||
None => return Err(bad(cmd, format!("`{name}` has no style `{sname}` (colour-dependent styles are Color_1 to Color_255)"))),
|
||||
};
|
||||
if let Some(st) = t.styles.get_mut(idx) {
|
||||
edit_style(cmd, st, v)?;
|
||||
}
|
||||
}
|
||||
let t = t.sanitized();
|
||||
let out = serde_json::to_value(&t).unwrap_or(Value::Null);
|
||||
store(s.doc_mut()?, t);
|
||||
Ok(out)
|
||||
}
|
||||
"delete" => {
|
||||
let name = table_name(cmd, p)?;
|
||||
let d = s.doc_mut()?;
|
||||
let before = d.plot_style_tables.len();
|
||||
d.plot_style_tables.retain(|t| !t.name.eq_ignore_ascii_case(&name));
|
||||
if d.plot_style_tables.len() == before {
|
||||
return Err(bad(cmd, format!("the drawing keeps no plot style table `{name}` (built-in tables can't be deleted)")));
|
||||
}
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
"load" => {
|
||||
let text = match (str_param(p, "text"), str_param(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`path` or `text` is required")),
|
||||
};
|
||||
let mut t = PlotStyleTable::from_file_text(&text).map_err(|e| bad(cmd, e))?;
|
||||
if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
t.name = n.chars().take(255).collect();
|
||||
}
|
||||
if t.name.is_empty() {
|
||||
return Err(bad(cmd, "the table has no name: pass `name`"));
|
||||
}
|
||||
let out = table_summary(s.doc()?, &t);
|
||||
store(s.doc_mut()?, t);
|
||||
Ok(out)
|
||||
}
|
||||
"save" => {
|
||||
let name = table_name(cmd, p)?;
|
||||
let text = find_table(cmd, s.doc()?, &name)?.to_file_text();
|
||||
match str_param(p, "path") {
|
||||
None => Ok(json!({ "name": name, "text": text })),
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{_path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad(cmd, "files can't be written here: leave out `path` to get `text`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
other => Err(bad(cmd, format!("unknown action `{other}` (list, get, new, set, delete, load, save)"))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! LAYOUT / -LAYOUT typed at the command line: one option, then the layout name(s) it needs.
|
||||
//! Every option runs the `layout.*` command bodies, so validation matches the JSON form and the
|
||||
//! command is one undo step.
|
||||
|
||||
use cadcraft_doc::Space;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::find_command;
|
||||
use super::super::qselect::wildcard;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const OPTIONS: &[&str] = &["Copy", "Delete", "New", "Template", "Rename", "SAveas", "Set", "?"];
|
||||
|
||||
/// Keyword-only: spaces separate inputs.
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Option,
|
||||
CopyFrom,
|
||||
CopyTo(String),
|
||||
Delete,
|
||||
New,
|
||||
RenameFrom,
|
||||
RenameTo(String),
|
||||
Set,
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LayoutM {
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
/// The layout the name prompts default to: the current one, else the first tab.
|
||||
fn current_layout(s: &Session) -> Option<String> {
|
||||
if let Space::Paper(n) = s.layout_space() {
|
||||
return Some(n);
|
||||
}
|
||||
let d = s.doc().ok()?;
|
||||
d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone())
|
||||
}
|
||||
|
||||
/// The name typed at a name prompt (`None` for Enter, which takes the default).
|
||||
fn typed_name(i: &Input) -> Option<String> {
|
||||
match i {
|
||||
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine refusal as a re-prompt message (no `invalid parameters for …` prefix).
|
||||
fn retry(e: EngineError) -> EngineError {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => EngineError::Other(msg),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn need(name: Option<String>) -> Result<String> {
|
||||
name.ok_or_else(|| EngineError::Other("Requires a layout name.".into()))
|
||||
}
|
||||
|
||||
impl LayoutM {
|
||||
fn option(&mut self, s: &mut Session, k: &str) -> Result<Step> {
|
||||
self.stage = match k {
|
||||
"Copy" => Stage::CopyFrom,
|
||||
"Delete" => Stage::Delete,
|
||||
"New" => Stage::New,
|
||||
"Rename" => Stage::RenameFrom,
|
||||
"Set" => Stage::Set,
|
||||
"?" => Stage::List,
|
||||
other => {
|
||||
// Template, SAveas: report and end, leaving the next input to the command line.
|
||||
s.echo(format!("LAYOUT {other} is not available yet."));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, pat: &str) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let mut ls: Vec<_> = d.layouts.iter().collect();
|
||||
ls.sort_by_key(|l| l.tab_order);
|
||||
let pats: Vec<&str> = pat.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect();
|
||||
let pats = if pats.is_empty() { vec!["*"] } else { pats };
|
||||
let names: Vec<String> = ls.iter().filter(|l| pats.iter().any(|p| wildcard(p, &l.name))).map(|l| l.name.clone()).collect();
|
||||
let current = match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
};
|
||||
s.echo(format!("Active Layout: {current}"));
|
||||
if names.is_empty() {
|
||||
s.echo(format!("No layout matches \"{pat}\"."));
|
||||
}
|
||||
for n in names {
|
||||
s.echo(format!("Layout: {n}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LayoutM {
|
||||
fn name(&self) -> &'static str {
|
||||
"LAYOUT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = current_layout(s);
|
||||
let with_cur = |p: Prompt| match &cur {
|
||||
Some(c) => p.default(c.clone()),
|
||||
None => p,
|
||||
};
|
||||
match &self.stage {
|
||||
Stage::Option => Prompt::new("Enter layout option", KW).kw(OPTIONS).default("set"),
|
||||
Stage::CopyFrom => with_cur(Prompt::new("Enter name of layout to copy", Accept::TEXT)),
|
||||
Stage::CopyTo(from) => {
|
||||
let next = s.doc().ok().and_then(|d| (2..=10_000).map(|i| format!("{from} ({i})")).find(|n| d.layout(n).is_none()));
|
||||
let p = Prompt::new("Enter layout name for copy", Accept::TEXT);
|
||||
match next {
|
||||
Some(n) => p.default(n),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
Stage::Delete => with_cur(Prompt::new("Enter name of layout to delete", Accept::TEXT)),
|
||||
Stage::New => {
|
||||
let next = s.doc().map(super::next_layout_name).unwrap_or_else(|_| "Layout1".into());
|
||||
Prompt::new("Enter name of new layout", Accept::TEXT).default(next)
|
||||
}
|
||||
Stage::RenameFrom => with_cur(Prompt::new("Enter layout to rename", Accept::TEXT)),
|
||||
Stage::RenameTo(_) => Prompt::new("Enter new layout name", Accept::TEXT),
|
||||
Stage::Set => {
|
||||
let tab = match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
};
|
||||
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
|
||||
}
|
||||
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if i == Input::Cancel {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let name = typed_name(&i);
|
||||
match self.stage.clone() {
|
||||
Stage::Option => match i {
|
||||
Input::Enter => {
|
||||
self.stage = Stage::Set;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => self.option(s, &k),
|
||||
_ => Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
Stage::CopyFrom => {
|
||||
let from = need(name.or_else(|| current_layout(s)))?;
|
||||
let from = super::layout_name("layout.copy", s.doc()?, &from).map_err(retry)?;
|
||||
self.stage = Stage::CopyTo(from);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::CopyTo(from) => {
|
||||
let mut p = json!({ "from": from });
|
||||
if let (Some(n), Some(o)) = (name, p.as_object_mut()) {
|
||||
o.insert("to".into(), json!(n));
|
||||
}
|
||||
let r = super::run_copy(s, &p).map_err(retry)?;
|
||||
let to = r.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
s.echo(format!("Layout \"{from}\" copied to \"{to}\"."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::Delete => {
|
||||
let n = need(name.or_else(|| current_layout(s)))?;
|
||||
let r = super::run_delete(s, &json!({ "name": n })).map_err(retry)?;
|
||||
let gone = r.get("deleted").and_then(Value::as_str).unwrap_or_default();
|
||||
s.echo(format!("Layout \"{gone}\" deleted."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::New => {
|
||||
let p = match name {
|
||||
Some(n) => json!({ "name": n }),
|
||||
None => json!({}),
|
||||
};
|
||||
super::run_new(s, &p).map_err(retry)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::RenameFrom => {
|
||||
let from = need(name.or_else(|| current_layout(s)))?;
|
||||
let from = super::layout_name("layout.rename", s.doc()?, &from).map_err(retry)?;
|
||||
self.stage = Stage::RenameTo(from);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::RenameTo(from) => {
|
||||
let to = need(name)?;
|
||||
super::run_rename(s, &json!({ "from": from, "to": to })).map_err(retry)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::Set => {
|
||||
let n = match name {
|
||||
Some(n) => n,
|
||||
None => match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
},
|
||||
};
|
||||
let set = find_command("layout.set").ok_or_else(|| EngineError::UnknownCommand("layout.set".into()))?;
|
||||
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::List => {
|
||||
Self::list(s, name.as_deref().unwrap_or("*"))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! VPLAYER typed at the command line: an option loop (ends on Enter) that freezes, thaws, resets
|
||||
//! or recolours layers in chosen viewports, and sets layers' default visibility in new
|
||||
//! viewports. Changes go through `viewport.set` / `layer.new` / `layer.set`, so validation
|
||||
//! matches the JSON forms and the whole session is one undo step.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Handle, Space};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::find_command;
|
||||
use super::super::qselect::wildcard;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const OPTIONS: &[&str] = &["?", "Color", "Freeze", "Thaw", "Reset", "Newfrz", "Vpvisdflt"];
|
||||
|
||||
/// Keyword-only: spaces separate inputs.
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
/// A name or value: Space ends the input, as in AutoCAD scripts.
|
||||
const NAME: Accept = Accept { number: true, ..Accept::TEXT };
|
||||
|
||||
/// A per-viewport layer change.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Change {
|
||||
Freeze,
|
||||
Thaw,
|
||||
/// Back to the layer's default for new viewports (its "VP freeze in new viewports" flag).
|
||||
Reset,
|
||||
Color(Color),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Option,
|
||||
/// `?`: the viewport whose frozen layers to list.
|
||||
List,
|
||||
Color,
|
||||
TrueColor,
|
||||
Layers(Change),
|
||||
Apply(Change, Vec<String>),
|
||||
Select(Change, Vec<String>, Vec<Handle>),
|
||||
Newfrz,
|
||||
VisLayers,
|
||||
VisDefault(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct VplayerM {
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
fn retry(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
fn message(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an engine command's body directly: the running command records the single undo step.
|
||||
fn call(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.run)(s, p)
|
||||
}
|
||||
|
||||
/// Layers matching a comma-separated name list (wildcards allowed), in drawing order.
|
||||
fn matching(s: &mut Session, list: &str) -> Vec<String> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
for pat in list.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000) {
|
||||
let before = out.len();
|
||||
for l in &d.layers {
|
||||
if wildcard(pat, &l.name) && !out.iter().any(|n| n.eq_ignore_ascii_case(&l.name)) {
|
||||
out.push(l.name.clone());
|
||||
}
|
||||
}
|
||||
if out.len() == before && !d.layers.iter().any(|l| wildcard(pat, &l.name)) {
|
||||
missing.push(pat.to_string());
|
||||
}
|
||||
}
|
||||
for m in missing {
|
||||
s.echo(format!("Cannot find layer \"{m}\"."));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn color_value(c: Color) -> Value {
|
||||
match c {
|
||||
Color::Index(i) => json!(i),
|
||||
Color::True(rgb) => json!(format!("{},{},{}", rgb.0, rgb.1, rgb.2)),
|
||||
Color::ByLayer | Color::ByBlock => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(t: &str) -> Result<Color> {
|
||||
match Color::parse(t) {
|
||||
Some(c @ (Color::Index(_) | Color::True(_))) => Ok(c),
|
||||
_ => Err(retry("Requires a color name, a number from 1 to 255 or Red,Green,Blue.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport under a picked point: the point is on the sheet in paper space, and in the
|
||||
/// active viewport's model space inside it (mapped back to the sheet).
|
||||
fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let st = s.state().ok()?;
|
||||
let at = match st.active_viewport() {
|
||||
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
|
||||
None => p,
|
||||
};
|
||||
super::layout_viewports(s)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|(_, v)| (at.x - v.center.x).abs() <= v.width / 2.0 && (at.y - v.center.y).abs() <= v.height / 2.0)
|
||||
.map(|(h, _)| h)
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> Option<Handle> {
|
||||
s.state().ok()?.active_viewport().map(|(h, _)| h)
|
||||
}
|
||||
|
||||
impl VplayerM {
|
||||
fn option(&mut self, s: &mut Session, k: &str) -> Result<Step> {
|
||||
self.stage = match k {
|
||||
"?" => match active(s) {
|
||||
Some(h) => {
|
||||
Self::list(s, h);
|
||||
Stage::Option
|
||||
}
|
||||
None => Stage::List,
|
||||
},
|
||||
"Color" => Stage::Color,
|
||||
"Freeze" => Stage::Layers(Change::Freeze),
|
||||
"Thaw" => Stage::Layers(Change::Thaw),
|
||||
"Reset" => Stage::Layers(Change::Reset),
|
||||
"Newfrz" => Stage::Newfrz,
|
||||
"Vpvisdflt" => Stage::VisLayers,
|
||||
_ => return Err(retry("Invalid option keyword.")),
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, h: Handle) {
|
||||
let Some(v) = super::layout_viewports(s).ok().and_then(|vs| vs.into_iter().find(|(x, _)| *x == h)).map(|(_, v)| v) else { return };
|
||||
s.echo(format!("Layers currently frozen in viewport {}:", v.id));
|
||||
if v.frozen_layers.is_empty() {
|
||||
s.echo(" (none)");
|
||||
}
|
||||
for l in v.frozen_layers {
|
||||
s.echo(format!(" {l}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `change` to `layers` in the viewports `hs`; refusals are reported.
|
||||
fn apply(s: &mut Session, change: &Change, layers: &[String], hs: &[Handle]) {
|
||||
if hs.is_empty() || layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let handles: Vec<String> = hs.iter().map(|h| h.hex()).collect();
|
||||
let mut calls = Vec::new();
|
||||
match change {
|
||||
Change::Freeze => calls.push(json!({ "handles": handles, "freeze": layers })),
|
||||
Change::Thaw => calls.push(json!({ "handles": handles, "thaw": layers })),
|
||||
Change::Reset => {
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let (frz, thaw): (Vec<String>, Vec<String>) = layers.iter().cloned().partition(|n| d.layer(n).is_some_and(|l| l.vp_freeze_new));
|
||||
calls.push(json!({ "handles": handles, "freeze": frz, "thaw": thaw }));
|
||||
}
|
||||
Change::Color(c) => {
|
||||
let colors: serde_json::Map<String, Value> = layers.iter().map(|l| (l.clone(), color_value(*c))).collect();
|
||||
calls.push(json!({ "handles": handles, "colors": colors }));
|
||||
}
|
||||
}
|
||||
for p in calls {
|
||||
if let Err(e) = super::run_viewport_set(s, &p) {
|
||||
s.echo(message(&e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(&mut self, s: &mut Session, stage: Stage, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
_ => None,
|
||||
};
|
||||
self.stage = match stage {
|
||||
Stage::Option => Stage::Option,
|
||||
Stage::List => match i {
|
||||
Input::Point(p) => {
|
||||
match viewport_at(s, p) {
|
||||
Some(h) => Self::list(s, h),
|
||||
None => s.echo("No viewport found there."),
|
||||
}
|
||||
Stage::Option
|
||||
}
|
||||
Input::Enter => Stage::Option,
|
||||
_ => {
|
||||
self.stage = Stage::List;
|
||||
return Err(retry("Requires a point inside a viewport."));
|
||||
}
|
||||
},
|
||||
Stage::Color => match (&i, text) {
|
||||
(Input::Keyword(k), _) if k == "Truecolor" => Stage::TrueColor,
|
||||
(_, Some(t)) => match parse_color(&t) {
|
||||
Ok(c) => Stage::Layers(Change::Color(c)),
|
||||
Err(e) => {
|
||||
self.stage = Stage::Color;
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
_ => Stage::Option,
|
||||
},
|
||||
Stage::TrueColor => match text {
|
||||
Some(t) => match parse_color(&t) {
|
||||
Ok(c) => Stage::Layers(Change::Color(c)),
|
||||
Err(e) => {
|
||||
self.stage = Stage::TrueColor;
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
None => Stage::Option,
|
||||
},
|
||||
Stage::Layers(change) => match text {
|
||||
Some(t) => {
|
||||
let layers = matching(s, &t);
|
||||
if layers.is_empty() { Stage::Option } else { Stage::Apply(change, layers) }
|
||||
}
|
||||
None => Stage::Option,
|
||||
},
|
||||
Stage::Apply(change, layers) => {
|
||||
let in_vp = active(s);
|
||||
let k = match (&i, in_vp) {
|
||||
(Input::Keyword(k), _) => k.as_str(),
|
||||
(Input::Enter, Some(_)) => "Current",
|
||||
(Input::Enter, None) => "Select",
|
||||
_ => {
|
||||
self.stage = Stage::Apply(change, layers);
|
||||
return Err(retry("Invalid option keyword."));
|
||||
}
|
||||
};
|
||||
let all: Vec<Handle> = super::layout_viewports(s).map(|v| v.into_iter().map(|(h, _)| h).collect()).unwrap_or_default();
|
||||
match k {
|
||||
"Select" => Stage::Select(change, layers, Vec::new()),
|
||||
"All" => {
|
||||
Self::apply(s, &change, &layers, &all);
|
||||
Stage::Option
|
||||
}
|
||||
"Current" => {
|
||||
Self::apply(s, &change, &layers, &in_vp.into_iter().collect::<Vec<_>>());
|
||||
Stage::Option
|
||||
}
|
||||
_ => {
|
||||
let others: Vec<Handle> = all.into_iter().filter(|h| Some(*h) != in_vp).collect();
|
||||
Self::apply(s, &change, &layers, &others);
|
||||
Stage::Option
|
||||
}
|
||||
}
|
||||
}
|
||||
Stage::Select(change, layers, mut picked) => match i {
|
||||
Input::Point(p) => {
|
||||
match viewport_at(s, p) {
|
||||
Some(h) if !picked.contains(&h) => {
|
||||
picked.push(h);
|
||||
s.echo(format!("{} viewport(s) selected.", picked.len()));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => s.echo("No viewport found there."),
|
||||
}
|
||||
Stage::Select(change, layers, picked)
|
||||
}
|
||||
Input::Enter => {
|
||||
Self::apply(s, &change, &layers, &picked);
|
||||
Stage::Option
|
||||
}
|
||||
_ => {
|
||||
self.stage = Stage::Select(change, layers, picked);
|
||||
return Err(retry("Requires a point inside a viewport."));
|
||||
}
|
||||
},
|
||||
Stage::Newfrz => {
|
||||
if let Some(t) = text {
|
||||
let all: Vec<Handle> = s
|
||||
.doc()?
|
||||
.layouts
|
||||
.iter()
|
||||
.flat_map(|l| l.entities.iter())
|
||||
.filter(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Viewport(v) if v.id != 1))
|
||||
.map(|e| e.handle)
|
||||
.collect();
|
||||
let mut made = Vec::new();
|
||||
for n in t.split(',').map(str::trim).filter(|n| !n.is_empty()).take(1000) {
|
||||
match call(s, "layer.new", &json!({ "name": n, "newVpFreeze": true })) {
|
||||
Ok(_) => made.push(n.to_string()),
|
||||
Err(e) => s.echo(format!("Layer \"{n}\": {}", message(&e))),
|
||||
}
|
||||
}
|
||||
Self::apply(s, &Change::Freeze, &made, &all);
|
||||
}
|
||||
Stage::Option
|
||||
}
|
||||
Stage::VisLayers => match text {
|
||||
Some(t) => {
|
||||
let layers = matching(s, &t);
|
||||
if layers.is_empty() { Stage::Option } else { Stage::VisDefault(layers) }
|
||||
}
|
||||
None => Stage::Option,
|
||||
},
|
||||
Stage::VisDefault(layers) => {
|
||||
let frozen = match &i {
|
||||
Input::Keyword(k) => k == "Frozen",
|
||||
Input::Enter => false,
|
||||
_ => {
|
||||
self.stage = Stage::VisDefault(layers);
|
||||
return Err(retry("Invalid option keyword."));
|
||||
}
|
||||
};
|
||||
for n in &layers {
|
||||
if let Err(e) = call(s, "layer.set", &json!({ "name": n, "newVpFreeze": frozen })) {
|
||||
s.echo(format!("Layer \"{n}\": {}", message(&e)));
|
||||
}
|
||||
}
|
||||
Stage::Option
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for VplayerM {
|
||||
fn name(&self) -> &'static str {
|
||||
"VPLAYER"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if s.layout_space() == Space::Model {
|
||||
s.echo("** Command not allowed in Model Tab ** (switch to a layout first)");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match &self.stage {
|
||||
Stage::Option => Prompt::new("Enter an option", KW).kw(OPTIONS),
|
||||
Stage::List => Prompt::new("Select a viewport", Accept::POINT),
|
||||
Stage::Color => Prompt::new("Enter color name or number (1-255)", NAME).kw(&["Truecolor"]),
|
||||
Stage::TrueColor => Prompt::new("Red, Green, Blue", NAME),
|
||||
Stage::Layers(c) => {
|
||||
let what = match c {
|
||||
Change::Freeze => "to freeze",
|
||||
Change::Thaw => "to thaw",
|
||||
Change::Reset => "to reset",
|
||||
Change::Color(_) => "to change color",
|
||||
};
|
||||
Prompt::new(format!("Enter layer name(s) {what}"), NAME)
|
||||
}
|
||||
Stage::Apply(..) => {
|
||||
if active(s).is_some() {
|
||||
Prompt::new("Enter an option", KW).kw(&["All", "Select", "Current", "Except current"]).default("Current")
|
||||
} else {
|
||||
Prompt::new("Enter an option", KW).kw(&["All", "Select"]).default("Select")
|
||||
}
|
||||
}
|
||||
Stage::Select(..) => Prompt::new("Select viewports", Accept::POINT),
|
||||
Stage::Newfrz => Prompt::new("Enter name(s) of new layers frozen in all viewports", NAME),
|
||||
Stage::VisLayers => Prompt::new("Enter layer name(s) to change default viewport visibility", NAME),
|
||||
Stage::VisDefault(_) => Prompt::new("Enter a default viewport visibility option", KW).kw(&["Frozen", "Thawed"]).default("Thawed"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (&self.stage, &i) {
|
||||
(_, Input::Cancel) => Ok(Step::Cancel),
|
||||
(Stage::Option, Input::Enter) => Ok(Step::Done),
|
||||
(Stage::Option, Input::Keyword(k)) => {
|
||||
let k = k.clone();
|
||||
self.option(s, &k)
|
||||
}
|
||||
(Stage::Option, _) => Err(retry("Invalid option keyword.")),
|
||||
_ => {
|
||||
let stage = std::mem::take(&mut self.stage);
|
||||
self.sub(s, stage, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ pub fn number(t: &str) -> Option<f64> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -229,4 +229,108 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
assert!(s.doc().unwrap().layer("0").is_some_and(|l| l.on));
|
||||
}
|
||||
|
||||
/// The cursor shows the pick box exactly where a click picks objects (#34): "Select objects",
|
||||
/// single-object prompts, and the idle crosshair when PICKFIRST is on.
|
||||
#[test]
|
||||
fn pick_box_at_object_selection_prompts() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
assert!(s.picking_objects(), "idle with PICKFIRST on");
|
||||
s.settings.pickfirst = false;
|
||||
assert!(!s.picking_objects(), "idle with PICKFIRST off");
|
||||
s.settings.pickfirst = true;
|
||||
for (cmd, picks) in [("erase", true), ("fillet", true), ("offset", false), ("line", false), ("hatch", false)] {
|
||||
s.cmdline(cmd).unwrap();
|
||||
assert_eq!(s.picking_objects(), picks, "{cmd}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
// OFFSET's object pick, FILLET's radius, HATCH's select mode.
|
||||
s.cmdline("offset 2").unwrap();
|
||||
assert!(s.picking_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
s.cmdline("fillet r").unwrap();
|
||||
assert!(!s.picking_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
s.cmdline("hatch s").unwrap();
|
||||
assert!(s.picking_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
/// Commands offer the option and values used last time as the `<default>`, and Enter takes it
|
||||
/// (#34). JSON calls keep their documented defaults.
|
||||
#[test]
|
||||
fn prompts_remember_last_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap();
|
||||
let rect = s.doc().unwrap().model.iter().next().unwrap().handle;
|
||||
|
||||
// HATCH: the "Select objects" mode sticks; `P` there is still Previous, not "picK".
|
||||
s.cmdline("hatch s").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects or [picK internal point"), "{}", s.prompt_text());
|
||||
s.input(Input::Pick(vec![rect])).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "Enter creates the hatch");
|
||||
let kinds: Vec<&str> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect();
|
||||
assert_eq!(kinds, vec!["Hatch", "Polyline"]);
|
||||
s.cmdline("hatch").unwrap();
|
||||
assert!(s.prompt_text().contains("HATCH Select objects"), "{}", s.prompt_text());
|
||||
s.cmdline("p").unwrap();
|
||||
assert!(s.prompt_text().contains("HATCH Select objects"), "{}", s.prompt_text());
|
||||
// Pattern scale: the current value is the default, Enter keeps it.
|
||||
s.cmdline("c").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<1.0000>:"), "{}", s.prompt_text());
|
||||
s.cmdline("2").unwrap();
|
||||
s.cmdline("c").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<2.0000>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("k").unwrap();
|
||||
s.cancel();
|
||||
s.cmdline("hatch").unwrap();
|
||||
assert!(s.prompt_text().contains("HATCH Pick internal point"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// POLYGON: sides and Inscribed/Circumscribed.
|
||||
s.cmdline("polygon 5 30,0 c 2").unwrap();
|
||||
s.cmdline("polygon").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<5>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("40,0").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<C>:"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// FILLET radius and CHAMFER distances (the second defaults to the first).
|
||||
s.cmdline("fillet r 2").unwrap();
|
||||
s.cmdline("r").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<2.0000>:"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
s.cmdline("chamfer d 1.5").unwrap();
|
||||
assert!(s.prompt_text().contains("second chamfer distance <1.5000>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.f64("CHAMFERB", 0.0), 1.5);
|
||||
s.cancel();
|
||||
|
||||
// ROTATE angle and SCALE factor.
|
||||
s.set_selection(vec![rect]);
|
||||
s.cmdline("rotate 0,0 90").unwrap();
|
||||
s.set_selection(vec![rect]);
|
||||
s.cmdline("rotate 0,0").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<90>:"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
s.set_selection(vec![rect]);
|
||||
s.cmdline("scale 0,0 2").unwrap();
|
||||
s.set_selection(vec![rect]);
|
||||
s.cmdline("scale 0,0").unwrap();
|
||||
assert!(s.prompt_text().ends_with("<2.0000>:"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// JSON calls are not affected by what was remembered.
|
||||
let r = s.execute("polygon", &json!({"center": [60, 0], "radius": 1, "sides": 4})).unwrap();
|
||||
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!((p.vertices[0].p.dist(Vec2::new(60.0, 0.0)) - 1.0).abs() < 1e-9, "inscribed"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! MLEADER's leader types (straight, spline, none) and block content: applied to the multileader
|
||||
//! `mleader_kind` builds, for the prompt machine and the JSON form.
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle, Insert};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{bad, str_param};
|
||||
use crate::{EngineError, LeaderType, Result, Session};
|
||||
|
||||
/// How the multileader differs from a straight leader with text.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct Content {
|
||||
pub leader: LeaderType,
|
||||
/// Block content: the block name, and whether its extents centre (rather than its insertion
|
||||
/// point) attaches to the end of the landing.
|
||||
pub block: Option<(String, bool)>,
|
||||
}
|
||||
|
||||
/// A block MLEADER can use as content: defined, and not an anonymous (`*`) block.
|
||||
pub(super) fn block_name(s: &Session, name: &str) -> Result<String> {
|
||||
let name = name.trim();
|
||||
match s.doc()?.block(name) {
|
||||
Some(b) if !b.name.starts_with('*') => Ok(b.name.clone()),
|
||||
_ => Err(EngineError::Other(format!("Block \"{name}\" not found."))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the leader type and block content to a multileader from `mleader_kind`.
|
||||
pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result<EntityKind> {
|
||||
let EntityKind::MLeader(mut m) = k else { return Ok(k) };
|
||||
m.spline = c.leader == LeaderType::Spline;
|
||||
let land = m.landing.xy();
|
||||
// The side the content sits on, as `mleader_kind` chose it for the text.
|
||||
let dir = if m.leaders.first().and_then(|l| l.first()).is_some_and(|a| a.x > land.x) { -1.0 } else { 1.0 };
|
||||
if c.leader == LeaderType::None {
|
||||
// Content only, at the picked location (text from its top left corner).
|
||||
m.leaders.clear();
|
||||
m.dogleg = 0.0;
|
||||
if let Some(t) = &mut m.text {
|
||||
t.insert = m.landing;
|
||||
t.attach = 1;
|
||||
}
|
||||
}
|
||||
if let Some((name, center)) = &c.block {
|
||||
let anchor = land + Vec2::new(m.dogleg * dir, 0.0);
|
||||
let mut ins = Insert {
|
||||
block: name.clone(),
|
||||
insert: Vec3::new(anchor.x, anchor.y, m.landing.z),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
if *center {
|
||||
// Move the block so the centre of its extents lands on the anchor.
|
||||
let d = s.doc()?;
|
||||
let probe = Entity { handle: Handle(0), common: Default::default(), kind: EntityKind::Insert(ins.clone()) };
|
||||
let b = cadcraft_doc::entity_bounds(d, &probe, 0);
|
||||
if !b.is_empty() {
|
||||
let c = b.center();
|
||||
if c.is_finite() {
|
||||
ins.insert = Vec3::new(ins.insert.x + anchor.x - c.x, ins.insert.y + anchor.y - c.y, ins.insert.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
m.text = None;
|
||||
m.block = Some(ins);
|
||||
}
|
||||
Ok(EntityKind::MLeader(m))
|
||||
}
|
||||
|
||||
/// `leaderType` ("straight" | "spline" | "none"), `block` (a block name) and `blockAttach`
|
||||
/// ("center" | "insertion") from the JSON form.
|
||||
pub(super) fn json_content(s: &Session, p: &Value) -> Result<Content> {
|
||||
let leader = match str_param(p, "leaderType").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("straight") => LeaderType::Straight,
|
||||
Some("spline") => LeaderType::Spline,
|
||||
Some("none") => LeaderType::None,
|
||||
Some(_) => return Err(bad("mleader", "`leaderType` must be \"straight\", \"spline\" or \"none\"")),
|
||||
};
|
||||
let block = match str_param(p, "block") {
|
||||
None => None,
|
||||
Some(n) => {
|
||||
let center = match str_param(p, "blockAttach").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("center") => true,
|
||||
Some("insertion") => false,
|
||||
Some(_) => return Err(bad("mleader", "`blockAttach` must be \"center\" or \"insertion\"")),
|
||||
};
|
||||
Some((block_name(s, n).map_err(|e| bad("mleader", e.to_string()))?, center))
|
||||
}
|
||||
};
|
||||
Ok(Content { leader, block })
|
||||
}
|
||||
@@ -2,23 +2,32 @@
|
||||
//! AutoCAD's menu bar so the catalog doubles as the parity metric.
|
||||
|
||||
mod annotate;
|
||||
mod array;
|
||||
mod audit;
|
||||
mod blocks;
|
||||
pub mod clipboard;
|
||||
pub mod constraints;
|
||||
mod draw;
|
||||
mod draw2;
|
||||
mod edit;
|
||||
pub mod file;
|
||||
mod gripcmds;
|
||||
pub(crate) mod groups;
|
||||
mod hatch;
|
||||
mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod mleader_content;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
mod pline_opts;
|
||||
mod props;
|
||||
mod qselect;
|
||||
mod settings;
|
||||
mod spline_opts;
|
||||
mod table;
|
||||
mod trimextend;
|
||||
mod utility;
|
||||
mod view;
|
||||
|
||||
@@ -148,6 +157,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(audit::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(qselect::specs());
|
||||
v.extend(view::specs());
|
||||
@@ -157,7 +167,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(hatch::specs());
|
||||
v.extend(blocks::specs());
|
||||
v.extend(modify::specs());
|
||||
v.extend(array::specs());
|
||||
v.extend(modify2::specs());
|
||||
v.extend(groups::specs());
|
||||
v.extend(gripcmds::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(layout::specs());
|
||||
@@ -196,6 +208,29 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
|
||||
pub(crate) fn f64_req(cmd: &str, p: &Value, key: &str) -> Result<f64> {
|
||||
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` (number) is required")))
|
||||
}
|
||||
/// An optional size: `None` when absent or null, else a finite number above zero (or zero too
|
||||
/// with `zero_ok`). Anything else is refused, not clamped or made absolute, so a bad value never
|
||||
/// makes a degenerate object.
|
||||
pub(crate) fn size_param(cmd: &str, p: &Value, key: &str, zero_ok: bool) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_f64().filter(|x| x.is_finite()) {
|
||||
Some(x) if x > 0.0 || (zero_ok && x == 0.0) => Ok(Some(x)),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be {}", if zero_ok { "zero or positive" } else { "positive" }))),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// `pts` without consecutive repeats (closer than `1e-12`), as the interactive LINE ignores a
|
||||
/// point picked twice.
|
||||
pub(crate) fn distinct_points(pts: &[Vec2]) -> Vec<Vec2> {
|
||||
let mut out: Vec<Vec2> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if !out.last().is_some_and(|l| l.near(*p, 1e-12)) {
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
|
||||
p.get(key).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
@@ -226,16 +261,20 @@ pub(crate) fn point_req(cmd: &str, p: &Value, key: &str) -> Result<Vec2> {
|
||||
pub(crate) fn points_param(p: &Value, key: &str) -> Option<Vec<Vec2>> {
|
||||
p.get(key)?.as_array()?.iter().map(point_value).collect()
|
||||
}
|
||||
/// Handles from `handles` (hex strings or numbers) or the current selection.
|
||||
/// Handles from `handles` (hex strings or numbers), `handle`, or else the current selection. A
|
||||
/// `handle`/`handles` that can't be read is an error, never a silent fall back to the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<Handle>> {
|
||||
if let Some(a) = p.get("handles").and_then(Value::as_array) {
|
||||
let hs: Vec<Handle> = a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect();
|
||||
return Ok(hs);
|
||||
let handle = |v: &Value| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle));
|
||||
let not_handle = |key: &str, v: &Value| EngineError::Other(format!("`{key}`: {v} is not an object handle (a hex string such as \"1A2\")"));
|
||||
match p.get("handles") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) => return a.iter().map(|v| handle(v).ok_or_else(|| not_handle("handles", v))).collect(),
|
||||
Some(v) => return Err(EngineError::Other(format!("`handles` must be an array of object handles, got {v}"))),
|
||||
}
|
||||
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]);
|
||||
match p.get("handle") {
|
||||
None | Some(Value::Null) => Ok(s.selection()),
|
||||
Some(v) => Ok(vec![handle(v).ok_or_else(|| not_handle("handle", v))?]),
|
||||
}
|
||||
Ok(s.selection())
|
||||
}
|
||||
pub(crate) fn ok() -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
|
||||
+1112
-337
File diff suppressed because it is too large.
Load diff
@@ -649,9 +649,7 @@ fn spline_close(sp: &Spline) -> Spline {
|
||||
return sp.clone();
|
||||
}
|
||||
let mut out = if sp.fit.len() >= 2 {
|
||||
let mut f = sp.fit.clone();
|
||||
f.extend(sp.fit.first().copied());
|
||||
Spline::from_fit_points(&f)
|
||||
sp.refit(&sp.fit, true)
|
||||
} else {
|
||||
let mut c = sp.control.clone();
|
||||
c.extend(sp.control.first().copied());
|
||||
@@ -672,7 +670,7 @@ fn spline_open(sp: &Spline) -> Spline {
|
||||
}
|
||||
v
|
||||
};
|
||||
if sp.fit.len() >= 2 { Spline::from_fit_points(&trim(&sp.fit)) } else { Spline::from_control(trim(&sp.control), sp.degree) }
|
||||
if sp.fit.len() >= 2 { sp.refit(&trim(&sp.fit), false) } else { Spline::from_control(trim(&sp.control), sp.degree) }
|
||||
}
|
||||
|
||||
pub(crate) fn spline_to_poly(sp: &Spline, precision: usize) -> LwPolyline {
|
||||
@@ -689,21 +687,22 @@ pub(crate) fn spline_to_poly(sp: &Spline, precision: usize) -> LwPolyline {
|
||||
fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("splinedit", "`handle` is required"))?;
|
||||
let e = curves::entity(s, h)?;
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let EntityKind::Spline(sp) = &e.kind else { return Err(other("Object selected is not a spline.")) };
|
||||
let opt = str_param(p, "option").ok_or_else(|| bad("splinedit", "`option` is required"))?.to_ascii_lowercase();
|
||||
let k = match opt.as_str() {
|
||||
"close" => EntityKind::Spline(spline_close(sp)),
|
||||
"open" => EntityKind::Spline(spline_open(sp)),
|
||||
"reverse" => EntityKind::Spline(curves::reverse_spline(sp)),
|
||||
"purge" => EntityKind::Spline(Spline { fit: Vec::new(), ..sp.clone() }),
|
||||
"purge" => EntityKind::Spline(Spline { fit: Vec::new(), fit_opts: Default::default(), ..sp.clone() }),
|
||||
"refit" => {
|
||||
let fit = points_param(p, "fit").unwrap_or_else(|| sp.fit.clone());
|
||||
if fit.len() < 2 || fit.len() > MAX_GEN {
|
||||
return Err(other("The spline has no fit data; give `fit` points."));
|
||||
}
|
||||
let mut n = Spline::from_fit_points(&fit);
|
||||
n.closed = sp.closed;
|
||||
EntityKind::Spline(n)
|
||||
EntityKind::Spline(sp.refit(&fit, sp.closed))
|
||||
}
|
||||
"polyline" => EntityKind::LwPolyline(spline_to_poly(sp, p.get("precision").and_then(Value::as_u64).unwrap_or(10) as usize)),
|
||||
"move" => {
|
||||
@@ -713,9 +712,7 @@ fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !n.fit.is_empty() {
|
||||
let f = n.fit.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?;
|
||||
*f = to;
|
||||
let closed = n.closed;
|
||||
n = Spline::from_fit_points(&n.fit);
|
||||
n.closed = closed;
|
||||
n = n.refit(&n.fit, n.closed);
|
||||
} else {
|
||||
let c = n.control.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?;
|
||||
*c = to;
|
||||
@@ -1004,7 +1001,8 @@ impl Interactive for LengthenM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let num = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { number(t) } else { None } };
|
||||
let ang = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { crate::units::parse_angle(t) } else { None } };
|
||||
let au = s.angle_settings();
|
||||
let ang = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { au.amount(t) } else { None } };
|
||||
match (self.phase, &i) {
|
||||
(LPhase::Main, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
@@ -1430,6 +1428,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
EntityKind::MLeader(m) => {
|
||||
m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z));
|
||||
z(&mut m.landing);
|
||||
m.block.iter_mut().for_each(|b| z(&mut b.insert));
|
||||
}
|
||||
EntityKind::Hatch(h) => h.elevation = 0.0,
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
|
||||
@@ -1802,6 +1801,9 @@ fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option<Entity
|
||||
|
||||
/// Fillet two curves (lines, arcs, circles) with radius `r`. Returns the new arc, if any.
|
||||
pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result<Option<Handle>> {
|
||||
if curves::is_locked(s, h1) || curves::is_locked(s, h2) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let e1 = curves::entity(s, h1)?;
|
||||
let e2 = curves::entity(s, h2)?;
|
||||
let unsupported = || other("Fillet works on lines, arcs and circles (and polylines with the Polyline option).");
|
||||
@@ -1858,6 +1860,9 @@ pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p
|
||||
|
||||
/// Fillet every corner between two straight segments of a polyline. Returns the count.
|
||||
pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result<usize> {
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let e = curves::entity(s, h)?;
|
||||
let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) };
|
||||
let vs = &pl.vertices;
|
||||
@@ -2123,13 +2128,14 @@ pub(crate) fn arraypath(
|
||||
return Err(other("Select objects to array."));
|
||||
}
|
||||
let (n, step) = match (count, spacing) {
|
||||
(_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize + 1).clamp(1, MAX_GEN), sp),
|
||||
(_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp),
|
||||
(Some(n), _) => {
|
||||
let n = n.clamp(1, MAX_GEN);
|
||||
let n = n.max(1);
|
||||
(n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 })
|
||||
}
|
||||
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
|
||||
};
|
||||
super::array::check_size(s, n as u64, objs.len())?;
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
for i in 1..n {
|
||||
@@ -2146,19 +2152,26 @@ pub(crate) fn arraypath(
|
||||
fn run_arraypath(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?;
|
||||
let count = p.get("count").and_then(Value::as_u64).map(|n| n.min(MAX_GEN as u64) as usize);
|
||||
let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64);
|
||||
let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PathArrayM {
|
||||
pub(super) struct PathArrayM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
path: Option<Handle>,
|
||||
}
|
||||
|
||||
impl PathArrayM {
|
||||
/// Start at the path prompt with the objects already selected (ARRAY > PAth).
|
||||
pub(super) fn with_objects(objs: Vec<Handle>) -> Self {
|
||||
PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PathArrayM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARRAYPATH"
|
||||
@@ -2199,9 +2212,7 @@ impl Interactive for PathArrayM {
|
||||
}
|
||||
(Some(path), inp @ (Input::Text(_) | Input::Enter)) => {
|
||||
let n = match &inp {
|
||||
Input::Text(t) => {
|
||||
number(t).filter(|n| *n >= 1.0 && *n <= MAX_GEN as f64).ok_or_else(|| other("Requires a count of 1 or more."))? as usize
|
||||
}
|
||||
Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize,
|
||||
_ => 6,
|
||||
};
|
||||
arraypath(s, &self.objs, path, Some(n), None, true)?;
|
||||
@@ -2260,6 +2271,12 @@ fn run_textedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[derive(Default)]
|
||||
struct TextEditM {
|
||||
h: Option<(Handle, String)>,
|
||||
/// Edits made in this run, newest last: each object as it was before (the Undo option).
|
||||
edits: Vec<(Handle, EntityKind)>,
|
||||
/// Single mode: end after one edit (the Mode option; Multiple by default).
|
||||
single: bool,
|
||||
/// Asking for the edit mode.
|
||||
mode: bool,
|
||||
}
|
||||
|
||||
impl Interactive for TextEditM {
|
||||
@@ -2267,12 +2284,26 @@ impl Interactive for TextEditM {
|
||||
"TEXTEDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.mode {
|
||||
let current = if self.single { "Single" } else { "Multiple" };
|
||||
return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current);
|
||||
}
|
||||
match &self.h {
|
||||
None => Prompt::new("Select an annotation object or", Accept::POINT).kw(&["Undo", "Mode"]),
|
||||
None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]),
|
||||
Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::<String>()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.mode {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Single" => self.single = true,
|
||||
Input::Keyword(k) if k == "Multiple" => self.single = false,
|
||||
Input::Enter => {}
|
||||
_ => return Err(other("Enter Single or Multiple.")),
|
||||
}
|
||||
self.mode = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (&self.h, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
@@ -2281,16 +2312,29 @@ impl Interactive for TextEditM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some((h, _)), Input::Text(t) | Input::Keyword(t)) => {
|
||||
set_text(s, *h, &t)?;
|
||||
let h = *h;
|
||||
let before = curves::entity(s, h)?.kind;
|
||||
set_text(s, h, &t)?;
|
||||
self.edits.push((h, before));
|
||||
self.h = None;
|
||||
Ok(Step::Continue)
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(Some(_), Input::Enter) => {
|
||||
self.h = None;
|
||||
Ok(Step::Continue)
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Done),
|
||||
(None, Input::Keyword(_)) => Ok(Step::Continue),
|
||||
(None, Input::Keyword(k)) if k == "Undo" => {
|
||||
match self.edits.pop() {
|
||||
Some((h, before)) => set_kind(s, h, before)?,
|
||||
None => s.echo("Everything has been undone"),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, Input::Keyword(k)) if k == "Mode" => {
|
||||
self.mode = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ fn splinedit_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0], [6, 2]]})).unwrap());
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "close"})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.closed && x.fit.len() == 5));
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.closed && x.is_periodic() && x.fit.len() == 4));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "open"})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if !x.closed && x.fit.len() == 4));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "reverse"})).unwrap();
|
||||
@@ -641,3 +641,43 @@ fn explode_polyline3d_keeps_elevations() {
|
||||
.collect();
|
||||
assert_eq!(lines, vec![(5.0, 6.0), (6.0, 7.0)]);
|
||||
}
|
||||
|
||||
/// Snapshot of every model entity, to prove a command left the drawing untouched.
|
||||
fn model_dump(s: &Session) -> String {
|
||||
format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_commands_leave_locked_layer_objects_alone() {
|
||||
let mut s = Session::new();
|
||||
let a = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let b = h(&s.execute("line", &json!({"points": [[5, -5], [5, 5]]})).unwrap());
|
||||
let c = h(&s.execute("line", &json!({"points": [[0, 0], [0, 10]]})).unwrap());
|
||||
let e = h(&s.execute("line", &json!({"points": [[0, 10], [3, 10]]})).unwrap());
|
||||
let r = h(&s.execute("rectang", &json!({"p1": [20, 0], "p2": [25, 5]})).unwrap());
|
||||
let sp = h(&s.execute("spline", &json!({"control": [[0, 20], [2, 23], [4, 23], [6, 20]], "degree": 3})).unwrap());
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
|
||||
let before = model_dump(&s);
|
||||
// Commands that reject the request.
|
||||
assert!(s.execute("trim", &json!({"handle": a, "pick": [2, 0], "edges": [b]})).is_err());
|
||||
assert!(s.execute("extend", &json!({"handle": e, "pick": [3, 10], "edges": [b]})).is_err());
|
||||
assert!(s.execute("fillet", &json!({"h1": a, "p1": [8, 0], "h2": c, "p2": [0, 8], "radius": 2})).is_err());
|
||||
assert!(s.execute("splinedit", &json!({"handle": sp, "option": "move", "index": 1, "to": [2, 29]})).is_err());
|
||||
// Commands that skip the locked objects.
|
||||
s.execute("select", &json!({"handles": [a, r]})).unwrap();
|
||||
assert_eq!(s.execute("erase.selection", &json!({})).unwrap()["erased"], 0);
|
||||
assert_eq!(s.execute("cutclip", &json!({"handles": [a]})).unwrap()["cut"], 0);
|
||||
assert_eq!(s.execute("stretch", &json!({"window": [[-1, -1], [11, 1]], "delta": [10, 0]})).unwrap()["stretched"], 0);
|
||||
s.execute("explode", &json!({"handles": [r]})).unwrap();
|
||||
// The interactive STRETCH (crossing window, base point, second point) too.
|
||||
s.start("stretch").unwrap();
|
||||
for (x, y) in [(-1.0, -1.0), (11.0, 1.0), (0.0, 0.0), (10.0, 0.0)] {
|
||||
s.input(crate::Input::Point(Vec2::new(x, y))).unwrap();
|
||||
}
|
||||
assert_eq!(model_dump(&s), before);
|
||||
// Unlocked, the same edits work.
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": false})).unwrap();
|
||||
s.execute("trim", &json!({"handle": a, "pick": [2, 0], "edges": [b]})).unwrap();
|
||||
s.execute("explode", &json!({"handles": [r]})).unwrap();
|
||||
assert_ne!(model_dump(&s), before);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! MTEXT's options before the text (Height, Justify, Line spacing, Rotation, Style, Width,
|
||||
//! Columns) for the prompt machine in `draw.rs`.
|
||||
|
||||
use cadcraft_doc::MText;
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::curves;
|
||||
use super::machines::number;
|
||||
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// The justification keywords, in attachment order 1..=9.
|
||||
pub(super) const JUSTIFY: [&str; 9] = ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
|
||||
|
||||
/// Attachment code (1..=9) for a justification keyword.
|
||||
pub(super) fn attach_code(k: &str) -> Option<u8> {
|
||||
JUSTIFY.iter().position(|j| j.eq_ignore_ascii_case(k.trim())).map(|i| i as u8 + 1)
|
||||
}
|
||||
|
||||
/// What the options set before the text is typed.
|
||||
pub(super) struct MTextOpts {
|
||||
pub height: f64,
|
||||
pub attach: u8,
|
||||
/// Line spacing factor (1 = 5/3 of the text height between baselines).
|
||||
pub spacing: f64,
|
||||
pub exact: bool,
|
||||
pub rotation: f64,
|
||||
pub style: String,
|
||||
/// The Width option replaces the opposite corner.
|
||||
pub width: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for MTextOpts {
|
||||
fn default() -> Self {
|
||||
MTextOpts { height: 0.2, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style: "Standard".into(), width: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl MTextOpts {
|
||||
pub(super) fn new(s: &Session) -> Self {
|
||||
let (height, style) =
|
||||
s.doc().map(|d| (d.header.f64("TEXTSIZE", 0.2), d.header.str("TEXTSTYLE", "Standard"))).unwrap_or((0.2, "Standard".into()));
|
||||
MTextOpts { height, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style, width: None }
|
||||
}
|
||||
|
||||
/// The text box from the first corner and either the opposite corner or the Width answer:
|
||||
/// the insertion point is the box's attachment point, the box turned by the rotation about
|
||||
/// the first corner.
|
||||
pub(super) fn mtext(&self, first: Vec2, second: Option<Vec2>, contents: String) -> MText {
|
||||
let (insert, width) = match (second, self.width) {
|
||||
(Some(b), _) => {
|
||||
// The opposite corner in the box's own (rotated) frame.
|
||||
let l = (b - first).rotate(-self.rotation);
|
||||
let (x0, x1) = (l.x.min(0.0), l.x.max(0.0));
|
||||
let (y0, y1) = (l.y.min(0.0), l.y.max(0.0));
|
||||
let i = usize::from(self.attach.clamp(1, 9) - 1);
|
||||
let x = [x0, (x0 + x1) / 2.0, x1][i % 3];
|
||||
let y = [y1, (y0 + y1) / 2.0, y0][i / 3];
|
||||
(first + Vec2::new(x, y).rotate(self.rotation), x1 - x0)
|
||||
}
|
||||
(None, w) => (first, w.unwrap_or(0.0)),
|
||||
};
|
||||
MText {
|
||||
insert: Vec3::new(insert.x, insert.y, 0.0),
|
||||
height: self.height,
|
||||
width,
|
||||
attach: self.attach.clamp(1, 9),
|
||||
rotation: self.rotation,
|
||||
style: self.style.clone(),
|
||||
contents,
|
||||
line_spacing: self.spacing,
|
||||
line_spacing_exact: self.exact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A question an option asks before MTEXT goes back to "Specify opposite corner".
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MTextAsk {
|
||||
Height,
|
||||
Justify,
|
||||
SpacingType,
|
||||
SpacingFactor,
|
||||
Rotation,
|
||||
Style,
|
||||
Width,
|
||||
Columns,
|
||||
}
|
||||
|
||||
pub(super) enum Outcome {
|
||||
/// Ask this next (the same question again after an invalid answer).
|
||||
Ask(MTextAsk),
|
||||
/// Back to the opposite-corner prompt.
|
||||
Back,
|
||||
/// The Width answer replaces the opposite corner: go on to the text.
|
||||
Text,
|
||||
}
|
||||
|
||||
impl MTextAsk {
|
||||
pub(super) fn from_keyword(k: &str) -> Option<MTextAsk> {
|
||||
Some(match k {
|
||||
"Height" => MTextAsk::Height,
|
||||
"Justify" => MTextAsk::Justify,
|
||||
"Line spacing" => MTextAsk::SpacingType,
|
||||
"Rotation" => MTextAsk::Rotation,
|
||||
"Style" => MTextAsk::Style,
|
||||
"Width" => MTextAsk::Width,
|
||||
"Columns" => MTextAsk::Columns,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prompt(self, o: &MTextOpts, first: Vec2) -> Prompt {
|
||||
match self {
|
||||
MTextAsk::Height => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{:.4}", o.height)).base(first),
|
||||
MTextAsk::Justify => Prompt::new("Enter justification", curves::KW)
|
||||
.kw(&JUSTIFY)
|
||||
.default(JUSTIFY.get(usize::from(o.attach.clamp(1, 9) - 1)).copied().unwrap_or("TL")),
|
||||
MTextAsk::SpacingType => {
|
||||
Prompt::new("Enter line spacing type", curves::KW).kw(&["At least", "Exactly"]).default(if o.exact { "Exactly" } else { "At least" })
|
||||
}
|
||||
MTextAsk::SpacingFactor => Prompt::new("Enter line spacing factor or distance", Accept::NUMBER).default(format!("{}x", trim(o.spacing))),
|
||||
MTextAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default(trim(o.rotation.to_degrees())).base(first),
|
||||
MTextAsk::Style => Prompt::new("Enter style name", curves::KW).kw(&["?"]).default(o.style.clone()),
|
||||
MTextAsk::Width => Prompt::new("Specify width", Accept::POINT_OR_NUMBER).base(first),
|
||||
MTextAsk::Columns => Prompt::new("Enter column type", curves::KW).kw(&["Dynamic", "Static", "No"]).default("No"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn input(self, s: &mut Session, i: Input, o: &mut MTextOpts, first: Vec2) -> Result<Outcome> {
|
||||
let word = |i: &Input| match i {
|
||||
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_string()),
|
||||
_ => None,
|
||||
};
|
||||
match (self, &i) {
|
||||
(_, Input::Enter) => Ok(if self == MTextAsk::SpacingType { Outcome::Ask(MTextAsk::SpacingFactor) } else { Outcome::Back }),
|
||||
(MTextAsk::Height, Input::Point(p)) => set_height(s, o, first.dist(*p)),
|
||||
(MTextAsk::Height, Input::Text(t)) => set_height(s, o, number(t).unwrap_or(f64::NAN)),
|
||||
(MTextAsk::Justify, _) => {
|
||||
let k = word(&i).unwrap_or_default();
|
||||
o.attach = attach_code(&k).ok_or_else(|| other("Invalid option keyword."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::SpacingType, _) => {
|
||||
let k = word(&i).unwrap_or_default().to_ascii_lowercase();
|
||||
o.exact = match k.as_str() {
|
||||
"at least" | "a" | "at" => false,
|
||||
"exactly" | "e" => true,
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
};
|
||||
Ok(Outcome::Ask(MTextAsk::SpacingFactor))
|
||||
}
|
||||
(MTextAsk::SpacingFactor, Input::Text(t)) => {
|
||||
// "1.5x" is a factor; a plain number is the distance between lines (1x = 5/3 of the height).
|
||||
let t = t.trim();
|
||||
let f = match t.strip_suffix(['x', 'X']) {
|
||||
Some(f) => number(f),
|
||||
None => number(t).map(|d| d / (o.height * 5.0 / 3.0)),
|
||||
};
|
||||
o.spacing = f
|
||||
.filter(|f| (0.25 - 1e-9..=4.0 + 1e-9).contains(f))
|
||||
.ok_or_else(|| other("Requires a factor between 0.25x and 4x, or a distance."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Rotation, Input::Point(p)) => {
|
||||
o.rotation = first.angle_to(*p);
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Rotation, Input::Text(t)) => {
|
||||
o.rotation = s.angle_settings().direction(t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Style, _) => {
|
||||
let name = word(&i).unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
if name == "?" {
|
||||
let names: Vec<String> = d.text_styles.iter().map(|t| t.name.clone()).collect();
|
||||
s.echo(format!("Text styles: {}", names.join(", ")));
|
||||
return Ok(Outcome::Ask(MTextAsk::Style));
|
||||
}
|
||||
o.style = d.text_style(&name).map(|t| t.name.clone()).ok_or_else(|| other(format!("Cannot find text style \"{name}\".")))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Width, Input::Point(p)) => {
|
||||
// The distance along the text direction from the first corner.
|
||||
o.width = Some((*p - first).rotate(-o.rotation).x.abs());
|
||||
Ok(Outcome::Text)
|
||||
}
|
||||
(MTextAsk::Width, Input::Text(t)) => {
|
||||
o.width = Some(number(t).filter(|w| w.is_finite() && *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?);
|
||||
Ok(Outcome::Text)
|
||||
}
|
||||
(MTextAsk::Columns, _) => match word(&i).unwrap_or_default().to_ascii_lowercase().as_str() {
|
||||
"no" | "n" => Ok(Outcome::Back),
|
||||
"dynamic" | "d" | "static" | "s" => {
|
||||
s.echo("Text columns are not available yet; the text stays in one column.");
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
_ => Err(other("Invalid option keyword.")),
|
||||
},
|
||||
_ => Ok(Outcome::Ask(self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_height(s: &mut Session, o: &mut MTextOpts, h: f64) -> Result<Outcome> {
|
||||
if !h.is_finite() || h <= 0.0 {
|
||||
return Err(other("Requires a positive height."));
|
||||
}
|
||||
o.height = h;
|
||||
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
|
||||
/// A number without trailing zeros (`1.5`, `2`).
|
||||
fn trim(v: f64) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! PLINE options that ask further questions before a segment is drawn: arc mode's Angle,
|
||||
//! CEnter, Direction, Radius and Second pt, and line mode's Length. Each ends in the next
|
||||
//! segment from the last vertex `st`: its end point and bulge.
|
||||
|
||||
use cadcraft_geom::{Arc, TAU, Vec2, arc_to_bulge};
|
||||
|
||||
use super::curves::{arc_sca, arc_scl, arc_sea, arc_sed, arc_ser};
|
||||
use super::machines::number;
|
||||
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||
|
||||
/// A PLINE option waiting for its answers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PlineAsk {
|
||||
/// Angle: the included angle.
|
||||
Angle,
|
||||
/// Angle given: the end point, or [CEnter/Radius].
|
||||
AngleEnd(f64),
|
||||
AngleCenter(f64),
|
||||
AngleRadius(f64),
|
||||
/// Included angle and radius given: the direction of the chord.
|
||||
Chord(f64, f64),
|
||||
/// CEnter: the center point.
|
||||
Center,
|
||||
/// Center given: the end point, or [Angle/Length].
|
||||
CenterEnd(Vec2),
|
||||
CenterAngle(Vec2),
|
||||
CenterLength(Vec2),
|
||||
/// Direction: the tangent direction at the start of the arc.
|
||||
Direction,
|
||||
DirectionEnd(Vec2),
|
||||
/// Radius: the radius.
|
||||
Radius,
|
||||
/// Radius given: the end point, or [Angle].
|
||||
RadiusEnd(f64),
|
||||
RadiusAngle(f64),
|
||||
/// Second pt: the second point of a three-point arc.
|
||||
Second,
|
||||
SecondEnd(Vec2),
|
||||
/// Line mode's Length: a line segment this long, continuing the last segment.
|
||||
Length,
|
||||
}
|
||||
|
||||
/// What an answer leads to.
|
||||
pub(crate) enum Outcome {
|
||||
Ask(PlineAsk),
|
||||
/// The next segment: end point and bulge.
|
||||
Segment(Vec2, f64),
|
||||
/// Back to the arc (or line) prompt without a segment.
|
||||
Back,
|
||||
}
|
||||
|
||||
fn other(m: &str) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// The polyline segment an arc starting at `st` makes: its end point and bulge. A whole circle
|
||||
/// can't be one segment.
|
||||
fn arc_segment(st: Vec2, a: &Arc) -> Option<(Vec2, f64)> {
|
||||
let sweep = a.sweep();
|
||||
if !(1e-9..TAU - 1e-9).contains(&sweep) {
|
||||
return None;
|
||||
}
|
||||
let tol = 1e-6 * a.radius.max(1.0);
|
||||
if a.start_point().near(st, tol) {
|
||||
Some((a.end_point(), arc_to_bulge(sweep)))
|
||||
} else if a.end_point().near(st, tol) {
|
||||
Some((a.start_point(), arc_to_bulge(-sweep)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl PlineAsk {
|
||||
/// The option an arc-mode (or line-mode) keyword opens.
|
||||
pub(crate) fn from_keyword(k: &str, arc_mode: bool) -> Option<PlineAsk> {
|
||||
Some(match (k, arc_mode) {
|
||||
("Angle", true) => PlineAsk::Angle,
|
||||
("CEnter", true) => PlineAsk::Center,
|
||||
("Direction", true) => PlineAsk::Direction,
|
||||
("Radius", true) => PlineAsk::Radius,
|
||||
("Second pt", true) => PlineAsk::Second,
|
||||
("Length", false) => PlineAsk::Length,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(self, st: Vec2, tangent: Vec2, s: &Session) -> Prompt {
|
||||
let end = |kw: &[&str]| Prompt::new("Specify endpoint of arc", Accept::POINT).kw(kw).base(st);
|
||||
match self {
|
||||
PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_) => Prompt::new("Specify included angle", Accept::NUMBER),
|
||||
PlineAsk::AngleEnd(_) => end(&["CEnter", "Radius"]),
|
||||
PlineAsk::AngleCenter(_) | PlineAsk::Center => Prompt::new("Specify center point of arc", Accept::POINT).base(st),
|
||||
PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st),
|
||||
PlineAsk::Chord(a, _) => {
|
||||
let d = shown_direction(s, chord_default(tangent, a));
|
||||
Prompt::new("Specify direction of chord for arc", Accept::POINT_OR_NUMBER).base(st).default(d)
|
||||
}
|
||||
PlineAsk::CenterEnd(_) => end(&["Angle", "Length"]),
|
||||
PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st),
|
||||
PlineAsk::Direction => Prompt::new("Specify the tangent direction for the start point of arc", Accept::POINT_OR_NUMBER).base(st),
|
||||
PlineAsk::DirectionEnd(_) => Prompt::new("Specify endpoint of the arc", Accept::POINT).base(st),
|
||||
PlineAsk::RadiusEnd(_) => end(&["Angle"]),
|
||||
PlineAsk::Second => Prompt::new("Specify second point on arc", Accept::POINT).base(st),
|
||||
PlineAsk::SecondEnd(_) => Prompt::new("Specify end point of arc", Accept::POINT).base(st),
|
||||
PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st),
|
||||
}
|
||||
}
|
||||
|
||||
/// The arc (or line) the prompt would give for the point `p`.
|
||||
fn segment_at(self, st: Vec2, tangent: Vec2, p: Vec2) -> Option<(Vec2, f64)> {
|
||||
let a = match self {
|
||||
PlineAsk::AngleEnd(a) => arc_sea(st, p, a),
|
||||
PlineAsk::AngleCenter(a) => arc_sca(st, p, a),
|
||||
PlineAsk::CenterEnd(c) => (!p.near(c, 1e-12) && st.dist(c) > 1e-12).then(|| Arc::from_start_center_end(st, c, p)),
|
||||
PlineAsk::DirectionEnd(d) => arc_sed(st, p, d),
|
||||
PlineAsk::RadiusEnd(r) => arc_ser(st, p, r),
|
||||
PlineAsk::SecondEnd(m) => Arc::from_3_points(st, m, p),
|
||||
PlineAsk::Chord(a, r) => return self.chord_segment(st, a, r, st.angle_to(p)),
|
||||
PlineAsk::Length => return Some((st + tangent * st.dist(p), 0.0)),
|
||||
_ => None,
|
||||
}?;
|
||||
arc_segment(st, &a)
|
||||
}
|
||||
|
||||
/// The arc with included angle `a` and radius `r` whose chord runs in direction `dir`.
|
||||
fn chord_segment(self, st: Vec2, a: f64, r: f64, dir: f64) -> Option<(Vec2, f64)> {
|
||||
let chord = 2.0 * r * (a.abs() / 2.0).sin();
|
||||
let en = st + Vec2::from_angle(dir) * chord;
|
||||
arc_sea(st, en, a).and_then(|arc| arc_segment(st, &arc))
|
||||
}
|
||||
|
||||
/// Rubber-band segment for the cursor `c`.
|
||||
pub(crate) fn preview(self, st: Vec2, tangent: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
|
||||
self.segment_at(st, tangent, c)
|
||||
}
|
||||
|
||||
pub(crate) fn input(self, s: &Session, i: Input, st: Vec2, tangent: Vec2) -> Result<Outcome> {
|
||||
let angles = s.angle_settings();
|
||||
let invalid = || other("Invalid arc for these values.");
|
||||
// A typed or picked size (distance from the last vertex).
|
||||
let size = |i: &Input| -> Option<f64> {
|
||||
match i {
|
||||
Input::Point(p) => Some(st.dist(*p)),
|
||||
Input::Text(t) => number(t),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let segment = |r: Option<(Vec2, f64)>| r.map(|(e, b)| Outcome::Segment(e, b)).ok_or_else(invalid);
|
||||
if matches!(i, Input::Enter) && !matches!(self, PlineAsk::Chord(..)) {
|
||||
return Ok(Outcome::Back);
|
||||
}
|
||||
Ok(match (self, i) {
|
||||
(PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_), Input::Text(t)) => {
|
||||
let a = angles.rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?;
|
||||
if a.abs() < 1e-9 || a.abs() >= TAU - 1e-9 {
|
||||
return Err(other("The included angle must be between 0 and 360 degrees."));
|
||||
}
|
||||
match self {
|
||||
PlineAsk::CenterAngle(c) => segment(arc_sca(st, c, a).and_then(|arc| arc_segment(st, &arc)))?,
|
||||
PlineAsk::RadiusAngle(r) => Outcome::Ask(PlineAsk::Chord(a, r)),
|
||||
_ => Outcome::Ask(PlineAsk::AngleEnd(a)),
|
||||
}
|
||||
}
|
||||
(PlineAsk::AngleEnd(a), Input::Keyword(k)) if k == "CEnter" => Outcome::Ask(PlineAsk::AngleCenter(a)),
|
||||
(PlineAsk::AngleEnd(a), Input::Keyword(k)) if k == "Radius" => Outcome::Ask(PlineAsk::AngleRadius(a)),
|
||||
(PlineAsk::CenterEnd(c), Input::Keyword(k)) if k == "Angle" => Outcome::Ask(PlineAsk::CenterAngle(c)),
|
||||
(PlineAsk::CenterEnd(c), Input::Keyword(k)) if k == "Length" => Outcome::Ask(PlineAsk::CenterLength(c)),
|
||||
(PlineAsk::RadiusEnd(r), Input::Keyword(k)) if k == "Angle" => Outcome::Ask(PlineAsk::RadiusAngle(r)),
|
||||
(PlineAsk::Center, Input::Point(c)) => {
|
||||
if c.near(st, 1e-12) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Outcome::Ask(PlineAsk::CenterEnd(c))
|
||||
}
|
||||
(PlineAsk::Second, Input::Point(m)) => {
|
||||
if m.near(st, 1e-12) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Outcome::Ask(PlineAsk::SecondEnd(m))
|
||||
}
|
||||
(PlineAsk::Direction, i) => {
|
||||
let d = match i {
|
||||
Input::Point(p) => p - st,
|
||||
Input::Text(t) => Vec2::from_angle(angles.direction(&t).ok_or_else(|| other("Requires an angle."))?),
|
||||
_ => return Err(other("Requires a direction.")),
|
||||
};
|
||||
if d.normalized() == Vec2::ZERO {
|
||||
return Err(other("Requires a direction."));
|
||||
}
|
||||
Outcome::Ask(PlineAsk::DirectionEnd(d.normalized()))
|
||||
}
|
||||
(PlineAsk::AngleRadius(a), i) => {
|
||||
let r = size(&i).filter(|r| r.is_finite() && *r > 1e-12).ok_or_else(|| other("Requires a positive radius."))?;
|
||||
Outcome::Ask(PlineAsk::Chord(a, r))
|
||||
}
|
||||
// A negative radius gives the major arc.
|
||||
(PlineAsk::Radius, i) => {
|
||||
let r = size(&i).filter(|r| r.is_finite() && r.abs() > 1e-12).ok_or_else(|| other("Requires a nonzero radius."))?;
|
||||
Outcome::Ask(PlineAsk::RadiusEnd(r))
|
||||
}
|
||||
(PlineAsk::CenterLength(c), i) => {
|
||||
let l = size(&i).filter(|l| l.is_finite()).ok_or_else(|| other("Requires a distance."))?;
|
||||
segment(arc_scl(st, c, l).and_then(|arc| arc_segment(st, &arc)))?
|
||||
}
|
||||
(PlineAsk::Chord(a, r), i) => {
|
||||
let dir = match i {
|
||||
Input::Point(p) if !p.near(st, 1e-12) => st.angle_to(p),
|
||||
Input::Text(t) => angles.direction(&t).ok_or_else(|| other("Requires an angle."))?,
|
||||
Input::Enter => chord_default(tangent, a),
|
||||
_ => return Err(other("Requires a direction.")),
|
||||
};
|
||||
segment(self.chord_segment(st, a, r, dir))?
|
||||
}
|
||||
(PlineAsk::Length, i) => {
|
||||
let l = size(&i).filter(|l| l.is_finite() && *l > 1e-12).ok_or_else(|| other("Requires a positive length."))?;
|
||||
Outcome::Segment(st + tangent * l, 0.0)
|
||||
}
|
||||
(_, Input::Point(p)) => segment(self.segment_at(st, tangent, p))?,
|
||||
_ => return Err(other("Point or option keyword required.")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Default chord direction for an arc with included angle `a` that continues the tangent: the
|
||||
/// tangent turned by half the angle.
|
||||
fn chord_default(tangent: Vec2, a: f64) -> f64 {
|
||||
tangent.angle() + a / 2.0
|
||||
}
|
||||
|
||||
/// `dir` (radians counterclockwise from +X) as the user reads directions: from ANGBASE in the
|
||||
/// ANGDIR sense, in AUNITS.
|
||||
fn shown_direction(s: &Session, dir: f64) -> String {
|
||||
let au = s.angle_settings();
|
||||
let a = (dir - au.angbase) * if au.clockwise { -1.0 } else { 1.0 };
|
||||
let prec = s.doc().map(|d| d.header.i64("AUPREC", 0)).unwrap_or(0);
|
||||
crate::units::format_angle(a, au.aunits, prec)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units.
|
||||
//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME.
|
||||
|
||||
mod rename;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{EntityKind, Lineweight, Transparency};
|
||||
@@ -47,7 +49,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["mls"])
|
||||
.params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, current?} → styles"),
|
||||
CommandSpec::new("ddptype", "Point Style...", run_ptype).menu(&["Format", "Point Style..."]).params("{pdmode, pdsize}"),
|
||||
CommandSpec::new("rename", "Rename...", run_rename).menu(&["Format", "Rename..."]).params("{table: layer|linetype|style|dimstyle|block, from, to}"),
|
||||
CommandSpec::new("rename", "Rename...", run_rename)
|
||||
.menu(&["Format", "Rename..."])
|
||||
.alias(&["ren", "-rename"])
|
||||
.params("{table: layer|linetype|style|dimstyle|block|mleaderstyle|tablestyle|view|ucs, from, to}")
|
||||
.interactive(|_| Ok(Box::new(rename::RenameM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -263,8 +269,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = num("widthFactor").filter(|x| *x > 0.0) {
|
||||
t.width_factor = x;
|
||||
}
|
||||
if let Some(v) = p.get("position") {
|
||||
set_xy(&mut t.insert, v);
|
||||
// The position is the point the text is placed by: moving it moves the text.
|
||||
if let Some(to) = p.get("position").and_then(point_value) {
|
||||
let d = to - t.justify_point();
|
||||
for q in std::iter::once(&mut t.insert).chain(t.align_pt.as_mut()) {
|
||||
q.x += d.x;
|
||||
q.y += d.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => {
|
||||
@@ -372,7 +383,7 @@ fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_linetype(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(load) = str_param(p, "load") {
|
||||
let lib = cadcraft_doc::library::standard_linetypes();
|
||||
let lib = cadcraft_doc::library::standard_linetypes_for(d);
|
||||
let mut n = 0;
|
||||
for lt in lib {
|
||||
if (load == "*" || lt.name.eq_ignore_ascii_case(load)) && d.linetype(<.name).is_none() {
|
||||
@@ -520,7 +531,7 @@ fn run_style_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Text style names used by entities (in model, paper spaces and blocks) and dimension styles.
|
||||
fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec<String> {
|
||||
pub(super) fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec<String> {
|
||||
let mut out: Vec<String> = d.dim_styles.iter().map(|s| s.text_style.clone()).collect();
|
||||
out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone()));
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
@@ -635,6 +646,10 @@ fn run_dimstyle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "current", true) {
|
||||
d.header.set_str("DIMSTYLE", &name);
|
||||
}
|
||||
// Editing or making the current style current clears the SETVAR overrides.
|
||||
if d.header.str("DIMSTYLE", "Standard").eq_ignore_ascii_case(&name) {
|
||||
d.sync_dim_vars();
|
||||
}
|
||||
let mut out = dimstyles_json(d);
|
||||
if !rejected.is_empty()
|
||||
&& let Some(o) = out.as_object_mut()
|
||||
@@ -666,6 +681,7 @@ fn run_dimstyle_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
let n = d.dim_style(name).map(|t| t.name.clone()).ok_or_else(|| bad("dimstyle.current", format!("no dimension style `{name}`")))?;
|
||||
d.header.set_str("DIMSTYLE", &n);
|
||||
d.sync_dim_vars();
|
||||
Ok(dimstyles_json(d))
|
||||
}
|
||||
|
||||
@@ -838,8 +854,15 @@ fn run_mleaderstyle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_ptype(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// PDMODE is a shape 0..=4 plus 32 (circle) and/or 64 (square).
|
||||
let mode = match p.get("pdmode") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_i64().filter(|m| (0..=100).contains(m) && m & !96 <= 4).ok_or_else(|| bad("ddptype", "`pdmode` must be 0..4, plus 32 and/or 64"))?,
|
||||
),
|
||||
};
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(m) = p.get("pdmode").and_then(Value::as_i64) {
|
||||
if let Some(m) = mode {
|
||||
d.header.set_i64("PDMODE", m);
|
||||
}
|
||||
if let Some(z) = p.get("pdsize").and_then(Value::as_f64) {
|
||||
@@ -854,18 +877,56 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("rename", "`to` is required"))?.to_string();
|
||||
match table.as_str() {
|
||||
"layer" => {
|
||||
s.execute("layer.set", &json!({ "name": from, "newName": to }))?;
|
||||
// The command body, not `execute`: RENAME records the one undo step.
|
||||
let set = find_command("layer.set").ok_or_else(|| bad("rename", "no layer.set command"))?;
|
||||
(set.run)(s, &json!({ "name": from, "newName": to }))?;
|
||||
}
|
||||
"style" => {
|
||||
"block" => rename::block(s, &from, &to)?,
|
||||
"mleaderstyle" | "multileaderstyle" => rename::simple(s, "mleaderstyle", &from, &to)?,
|
||||
"tablestyle" | "view" | "ucs" => rename::simple(s, &table, &from, &to)?,
|
||||
"style" | "textstyle" => {
|
||||
run_style_rename(s, &json!({ "from": from, "to": to }))?;
|
||||
}
|
||||
"dimstyle" => {
|
||||
run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?;
|
||||
}
|
||||
"linetype" => {
|
||||
"linetype" | "ltype" => {
|
||||
let d = s.doc_mut()?;
|
||||
if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad("rename", format!("the {from} linetype cannot be renamed")));
|
||||
}
|
||||
if d.linetype(&to).is_some() && !to.eq_ignore_ascii_case(&from) {
|
||||
return Err(bad("rename", format!("a linetype `{to}` already exists")));
|
||||
}
|
||||
let st = d.linetypes.iter_mut().find(|t| t.name.eq_ignore_ascii_case(&from)).ok_or_else(|| bad("rename", "no such linetype"))?;
|
||||
st.name = to;
|
||||
st.name = to.clone();
|
||||
// References follow the new name.
|
||||
let fix = |n: &mut String| {
|
||||
if n.eq_ignore_ascii_case(&from) {
|
||||
*n = to.clone();
|
||||
}
|
||||
};
|
||||
d.layers.iter_mut().for_each(|l| fix(&mut l.linetype));
|
||||
if d.header.str("CELTYPE", "ByLayer").eq_ignore_ascii_case(&from) {
|
||||
d.header.set_str("CELTYPE", &to);
|
||||
}
|
||||
let users = |st: &cadcraft_doc::EntityStore| -> Vec<cadcraft_doc::Handle> {
|
||||
st.iter().filter(|e| e.common.linetype.eq_ignore_ascii_case(&from)).map(|e| e.handle).collect()
|
||||
};
|
||||
let mut hs = users(&d.model);
|
||||
hs.extend(d.layouts.iter().flat_map(|l| users(&l.entities)));
|
||||
for h in hs {
|
||||
d.modify_entity(h, |e| fix(&mut e.common.linetype))?;
|
||||
}
|
||||
for block in d.blocks.values_mut() {
|
||||
let hs = users(&block.entities);
|
||||
if !hs.is_empty() {
|
||||
let b = std::sync::Arc::make_mut(block);
|
||||
for h in hs {
|
||||
b.entities.modify(h, |e| fix(&mut e.common.linetype));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("rename", "unsupported table")),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! RENAME for blocks and the remaining named tables, and -RENAME's prompts.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::bad;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// Handles in `store` whose entity `uses` the old name.
|
||||
fn users(store: &EntityStore, uses: &dyn Fn(&EntityKind) -> bool) -> Vec<Handle> {
|
||||
store.iter().filter(|e| uses(&e.kind)).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// Apply `fix` to every entity that `uses` the old name: model space, layouts and block definitions.
|
||||
fn repoint(d: &mut Drawing, uses: &dyn Fn(&EntityKind) -> bool, fix: &dyn Fn(&mut EntityKind)) -> Result<()> {
|
||||
let mut hs = users(&d.model, uses);
|
||||
hs.extend(d.layouts.iter().flat_map(|l| users(&l.entities, uses)));
|
||||
for h in hs {
|
||||
d.modify_entity(h, |e| fix(&mut e.kind))?;
|
||||
}
|
||||
for block in d.blocks.values_mut() {
|
||||
let hs = users(&block.entities, uses);
|
||||
if !hs.is_empty() {
|
||||
let b = Arc::make_mut(block);
|
||||
for h in hs {
|
||||
b.entities.modify(h, |e| fix(&mut e.kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a block definition; inserts everywhere (nested ones and those with attributes
|
||||
/// included), INSNAME and dimension-style arrowheads follow.
|
||||
pub(super) fn block(s: &mut Session, from: &str, to: &str) -> Result<()> {
|
||||
let to = to.trim().to_string();
|
||||
if !valid_name(&to) {
|
||||
return Err(bad("rename", "invalid block name"));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(from)).cloned().ok_or_else(|| bad("rename", "no such block"))?;
|
||||
if key.starts_with('*') || d.blocks.get(&key).is_some_and(|b| b.anonymous) {
|
||||
return Err(bad("rename", "an anonymous block cannot be renamed"));
|
||||
}
|
||||
if !to.eq_ignore_ascii_case(&key) && d.block(&to).is_some() {
|
||||
return Err(bad("rename", format!("a block `{to}` already exists")));
|
||||
}
|
||||
let mut b = d.blocks.remove(&key).ok_or_else(|| bad("rename", "no such block"))?;
|
||||
Arc::make_mut(&mut b).name = to.clone();
|
||||
d.blocks.insert(to.clone(), b);
|
||||
let uses = |k: &EntityKind| match k {
|
||||
EntityKind::Insert(i) => i.block.eq_ignore_ascii_case(&key),
|
||||
EntityKind::MLeader(m) => m.block.as_ref().is_some_and(|b| b.block.eq_ignore_ascii_case(&key)),
|
||||
_ => false,
|
||||
};
|
||||
let fix = |k: &mut EntityKind| match k {
|
||||
EntityKind::Insert(i) => i.block = to.clone(),
|
||||
EntityKind::MLeader(m) => m.block.iter_mut().for_each(|b| b.block = to.clone()),
|
||||
_ => {}
|
||||
};
|
||||
repoint(d, &uses, &fix)?;
|
||||
if d.header.str("INSNAME", "").eq_ignore_ascii_case(&key) {
|
||||
d.header.set_str("INSNAME", &to);
|
||||
}
|
||||
for ds in &mut d.dim_styles {
|
||||
for a in [&mut ds.arrow_block, &mut ds.arrow_block1, &mut ds.arrow_block2] {
|
||||
if a.eq_ignore_ascii_case(&key) {
|
||||
*a = to.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a multileader style, a table style, a named view or a named UCS.
|
||||
pub(super) fn simple(s: &mut Session, table: &str, from: &str, to: &str) -> Result<()> {
|
||||
let to = to.trim().to_string();
|
||||
if !valid_name(&to) {
|
||||
return Err(bad("rename", "invalid name"));
|
||||
}
|
||||
if matches!(table, "mleaderstyle" | "tablestyle") && from.eq_ignore_ascii_case("Standard") {
|
||||
return Err(bad("rename", "the Standard style cannot be renamed"));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let mut names: Vec<&mut String> = match table {
|
||||
"mleaderstyle" => d.mleader_styles.iter_mut().map(|t| &mut t.name).collect(),
|
||||
"tablestyle" => d.table_styles.iter_mut().map(|t| &mut t.name).collect(),
|
||||
"view" => d.views.iter_mut().map(|t| &mut t.name).collect(),
|
||||
"ucs" => d.ucss.iter_mut().map(|t| &mut t.name).collect(),
|
||||
_ => return Err(bad("rename", "unsupported table")),
|
||||
};
|
||||
if !to.eq_ignore_ascii_case(from) && names.iter().any(|n| n.eq_ignore_ascii_case(&to)) {
|
||||
return Err(bad("rename", format!("`{to}` already exists")));
|
||||
}
|
||||
let n = names.iter_mut().find(|n| n.eq_ignore_ascii_case(from)).ok_or_else(|| bad("rename", format!("no {table} `{from}`")))?;
|
||||
let old = std::mem::replace(&mut **n, to.clone());
|
||||
let current = match table {
|
||||
"mleaderstyle" => "CMLEADERSTYLE",
|
||||
"tablestyle" => "CTABLESTYLE",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if d.header.str(current, "Standard").eq_ignore_ascii_case(&old) {
|
||||
d.header.set_str(current, &to);
|
||||
}
|
||||
let uses = |k: &EntityKind| match k {
|
||||
EntityKind::MLeader(m) => table == "mleaderstyle" && m.style.eq_ignore_ascii_case(&old),
|
||||
EntityKind::Table(t) => table == "tablestyle" && t.style.eq_ignore_ascii_case(&old),
|
||||
_ => false,
|
||||
};
|
||||
let fix = |k: &mut EntityKind| match k {
|
||||
EntityKind::MLeader(m) => m.style = to.clone(),
|
||||
EntityKind::Table(t) => t.style = to.clone(),
|
||||
_ => {}
|
||||
};
|
||||
repoint(d, &uses, &fix)
|
||||
}
|
||||
|
||||
// ---------------- -RENAME prompts ----------------
|
||||
|
||||
/// Prompt keyword, `rename` table (None: not available yet), noun.
|
||||
const TYPES: &[(&str, Option<&str>, &str)] = &[
|
||||
("Block", Some("block"), "block"),
|
||||
("Dimstyle", Some("dimstyle"), "dimension style"),
|
||||
("LAyer", Some("layer"), "layer"),
|
||||
("LType", Some("linetype"), "linetype"),
|
||||
("Material", None, "material"),
|
||||
("multileadeRstyle", Some("mleaderstyle"), "multileader style"),
|
||||
("Plotstyle", None, "plot style"),
|
||||
("textStyle", Some("style"), "text style"),
|
||||
("Tablestyle", Some("tablestyle"), "table style"),
|
||||
("Ucs", Some("ucs"), "UCS"),
|
||||
("VIew", Some("view"), "view"),
|
||||
("VPort", None, "viewport configuration"),
|
||||
];
|
||||
|
||||
/// Keyword-only: spaces separate inputs.
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
/// A name: Space ends the input.
|
||||
const NAME: Accept = Accept { number: true, ..Accept::TEXT };
|
||||
|
||||
fn any_named<'a>(mut names: impl Iterator<Item = &'a String>, name: &str) -> bool {
|
||||
names.any(|n| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn exists(d: &Drawing, table: &str, name: &str) -> bool {
|
||||
match table {
|
||||
"block" => d.block(name).is_some(),
|
||||
"layer" => d.layer(name).is_some(),
|
||||
"linetype" => d.linetype(name).is_some(),
|
||||
"style" => d.text_style(name).is_some(),
|
||||
"dimstyle" => d.dim_style(name).is_some(),
|
||||
"mleaderstyle" => any_named(d.mleader_styles.iter().map(|t| &t.name), name),
|
||||
"tablestyle" => any_named(d.table_styles.iter().map(|t| &t.name), name),
|
||||
"view" => any_named(d.views.iter().map(|t| &t.name), name),
|
||||
"ucs" => any_named(d.ucss.iter().map(|t| &t.name), name),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Type,
|
||||
Old(usize),
|
||||
New(usize, String),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::cmd) struct RenameM {
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
fn text(i: &Input) -> Option<String> {
|
||||
match i {
|
||||
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RenameM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-RENAME"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let noun = |t: &usize| TYPES.get(*t).map_or("object", |(_, _, n)| *n);
|
||||
match &self.stage {
|
||||
Stage::Type => {
|
||||
let kws: Vec<&str> = TYPES.iter().map(|(k, _, _)| *k).collect();
|
||||
Prompt::new("Enter object type to rename", KW).kw(&kws)
|
||||
}
|
||||
Stage::Old(t) => Prompt::new(format!("Enter old {} name", noun(t)), NAME),
|
||||
Stage::New(t, _) => Prompt::new(format!("Enter new {} name", noun(t)), NAME),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if matches!(i, Input::Cancel | Input::Enter) {
|
||||
return Ok(if i == Input::Cancel { Step::Cancel } else { Step::Done });
|
||||
}
|
||||
match std::mem::take(&mut self.stage) {
|
||||
Stage::Type => {
|
||||
let t = match &i {
|
||||
Input::Keyword(k) => TYPES.iter().position(|(kw, _, _)| kw == k),
|
||||
_ => None,
|
||||
};
|
||||
let Some(t) = t else { return Err(EngineError::Other("Invalid option keyword.".into())) };
|
||||
self.stage = Stage::Old(t);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Old(t) => {
|
||||
let (Some(name), Some((_, table, noun))) = (text(&i), TYPES.get(t)) else {
|
||||
self.stage = Stage::Old(t);
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
if let Some(table) = table
|
||||
&& !exists(s.doc()?, table, &name)
|
||||
{
|
||||
self.stage = Stage::Old(t);
|
||||
return Err(EngineError::Other(format!("No {noun} named \"{name}\".")));
|
||||
}
|
||||
self.stage = Stage::New(t, name);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::New(t, old) => {
|
||||
let (Some(name), Some((_, table, noun))) = (text(&i), TYPES.get(t)) else {
|
||||
self.stage = Stage::New(t, old);
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let Some(table) = table else {
|
||||
s.echo(format!("Renaming a {noun} is not available yet."));
|
||||
return Ok(Step::Done);
|
||||
};
|
||||
match super::run_rename(s, &json!({ "table": table, "from": old, "to": name })) {
|
||||
Ok(_) => Ok(Step::Done),
|
||||
Err(e) => {
|
||||
self.stage = Stage::New(t, old);
|
||||
Err(EngineError::Other(match e {
|
||||
EngineError::BadParams { msg, .. } => msg,
|
||||
other => other.to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value, get: fn(&mut crate::Settings) -> &mut bool, name: &str) -> Result<Value> {
|
||||
let v = get(&mut s.settings);
|
||||
@@ -91,7 +91,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Tools", "Inquiry", "Set Variable"])
|
||||
.alias(&["set"])
|
||||
.params("{name, value}")
|
||||
.transparent(),
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(SetvarM::default()))),
|
||||
CommandSpec::new("getvar", "Get Variable", run_getvar).params("{name}").enabled(always).noundo(),
|
||||
CommandSpec::new("sysvars", "List System Variables", |s, _| Ok(crate::sysvars::list(s))).enabled(always).noundo(),
|
||||
]
|
||||
@@ -165,6 +166,82 @@ fn run_setvar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": name.to_ascii_uppercase(), "value": crate::sysvars::get(s, name) }))
|
||||
}
|
||||
|
||||
/// A system variable value as the command line shows it.
|
||||
fn show_value(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Array(a) => a.iter().map(show_value).collect::<Vec<_>>().join(","),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// SETVAR at the command line: "Enter variable name or [?]", then "Enter new value for NAME <current>".
|
||||
#[derive(Default)]
|
||||
struct SetvarM {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl Interactive for SetvarM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETVAR"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
// Not a free-text prompt: Space ends the input, as it does for names and numbers.
|
||||
let accept = Accept { number: true, ..Accept::TEXT };
|
||||
match &self.name {
|
||||
None => Prompt::new("Enter variable name", accept).kw(&["?"]),
|
||||
Some(n) => {
|
||||
let p = Prompt::new(format!("Enter new value for {n}"), accept);
|
||||
match crate::sysvars::get(s, n) {
|
||||
Some(v) => p.default(show_value(&v)),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.name.clone(), i) {
|
||||
(_, Input::Enter | Input::Cancel) => Ok(Step::Done),
|
||||
(None, Input::Keyword(_)) => {
|
||||
if let Value::Object(m) = crate::sysvars::list(s) {
|
||||
for (k, v) in m {
|
||||
s.echo(format!("{k:<12} {}", show_value(&v)));
|
||||
}
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(None, Input::Text(t)) => {
|
||||
let n = t.trim().to_ascii_uppercase();
|
||||
match crate::sysvars::get(s, &n) {
|
||||
None => {
|
||||
s.echo(format!("Unknown variable {n}. Type SETVAR ? to list variables."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Some(v) if crate::sysvars::is_read_only(&n) => {
|
||||
s.echo(format!("{n} = {} (read only)", show_value(&v)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Some(_) => {
|
||||
self.name = Some(n);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(n), Input::Text(t)) => match crate::sysvars::set(s, &n, &Value::String(t)) {
|
||||
Ok(()) => {
|
||||
s.touch();
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(e) => {
|
||||
s.echo(e.to_string());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_getvar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("getvar", "`name` is required"))?;
|
||||
let v = crate::sysvars::get(s, name).ok_or_else(|| bad("getvar", format!("unknown variable `{name}`")))?;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! SPLINE's fit options: Knots, start/end Tangency, toLerance and Object (turn a polyline into a
|
||||
//! spline), for the prompt machine in `draw.rs` and the JSON form.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{FitOptions, KnotParam, Spline, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::{bad, curves, distinct_points, f64_or, point_param, targets};
|
||||
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// The knot parametrisation SPLINE starts with (SPLKNOTS: 0 chord, 1 square root, 2 uniform).
|
||||
pub(super) fn current_knots(s: &Session) -> KnotParam {
|
||||
s.doc().ok().and_then(|d| KnotParam::parse(&d.header.i64("SPLKNOTS", 0).to_string())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A question an option asks before SPLINE goes back to its point prompt.
|
||||
pub(super) enum SplineAsk {
|
||||
Knots,
|
||||
StartTangent,
|
||||
EndTangent,
|
||||
Tolerance,
|
||||
Object(SelectPhase),
|
||||
}
|
||||
|
||||
/// What the machine does after an answer.
|
||||
pub(super) enum Outcome {
|
||||
/// Ask again (an invalid answer was reported, or the selection goes on).
|
||||
Stay,
|
||||
/// Back to the point prompt.
|
||||
Back,
|
||||
/// Make the spline from the points picked so far and end (after the end tangent).
|
||||
Finish,
|
||||
/// The command is over (Object converted its selection, or nothing was selected).
|
||||
Done,
|
||||
}
|
||||
|
||||
impl SplineAsk {
|
||||
/// The option for a keyword at the point prompt, `None` for keywords handled elsewhere.
|
||||
pub(super) fn from_keyword(k: &str) -> Option<SplineAsk> {
|
||||
Some(match k {
|
||||
"Knots" => SplineAsk::Knots,
|
||||
"start Tangency" => SplineAsk::StartTangent,
|
||||
"end Tangency" => SplineAsk::EndTangent,
|
||||
"toLerance" => SplineAsk::Tolerance,
|
||||
"Object" => SplineAsk::Object(SelectPhase::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prompt(&self, opts: &FitOptions, pts: &[Vec2]) -> Prompt {
|
||||
match self {
|
||||
SplineAsk::Knots => {
|
||||
Prompt::new("Enter knot parameterization", curves::KW).kw(&["Chord", "Square root", "Uniform"]).default(opts.knots.name())
|
||||
}
|
||||
SplineAsk::StartTangent => Prompt::new("Specify start tangent", Accept::POINT_OR_NUMBER).base_opt(pts.first().copied()),
|
||||
SplineAsk::EndTangent => Prompt::new("Specify end tangent", Accept::POINT_OR_NUMBER).base_opt(pts.last().copied()),
|
||||
SplineAsk::Tolerance => Prompt::new("Specify a fit tolerance", Accept::NUMBER).default(format!("{:.4}", opts.tolerance)),
|
||||
SplineAsk::Object(sel) => {
|
||||
let p = sel.prompt();
|
||||
if sel.removing { p } else { Prompt { message: "Select spline-fit polylines to convert".into(), ..p } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn input(&mut self, s: &mut Session, i: Input, opts: &mut FitOptions, pts: &[Vec2]) -> Result<Outcome> {
|
||||
match self {
|
||||
SplineAsk::Knots => {
|
||||
let k = match &i {
|
||||
Input::Keyword(k) | Input::Text(k) => KnotParam::parse(k).ok_or_else(|| other("Invalid option keyword."))?,
|
||||
Input::Enter => opts.knots,
|
||||
_ => return Ok(Outcome::Stay),
|
||||
};
|
||||
opts.knots = k;
|
||||
s.doc_mut()?.header.set_i64("SPLKNOTS", k.code());
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
SplineAsk::StartTangent | SplineAsk::EndTangent => {
|
||||
let start = matches!(self, SplineAsk::StartTangent);
|
||||
let from = if start { pts.first() } else { pts.last() }.copied().unwrap_or_default();
|
||||
let dir = match i {
|
||||
Input::Point(p) => {
|
||||
let d = p - from;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(other("The tangent point must differ from the fit point."));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
// A typed direction (angle), measured as at any angle prompt.
|
||||
Input::Text(t) => Some(Vec2::from_angle(s.angle_settings().direction(&t).ok_or_else(|| other("Requires a point or an angle."))?)),
|
||||
// Enter: no tangent, the end follows the fit points.
|
||||
Input::Enter => None,
|
||||
_ => return Ok(Outcome::Stay),
|
||||
};
|
||||
if start {
|
||||
opts.start_tangent = dir.map(Vec2::normalized);
|
||||
Ok(Outcome::Back)
|
||||
} else {
|
||||
opts.end_tangent = dir.map(Vec2::normalized);
|
||||
Ok(Outcome::Finish)
|
||||
}
|
||||
}
|
||||
SplineAsk::Tolerance => {
|
||||
let t = match i {
|
||||
Input::Text(t) => number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires a non-negative number."))?,
|
||||
Input::Enter => opts.tolerance,
|
||||
_ => return Ok(Outcome::Stay),
|
||||
};
|
||||
opts.tolerance = t;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
SplineAsk::Object(sel) => match sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Outcome::Stay),
|
||||
SelOutcome::Empty => Ok(Outcome::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
let n = convert_objects(s, &hs, *opts)?;
|
||||
s.echo(format!("{n} object(s) converted to splines"));
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Outcome::Done)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The spline for a polyline's vertices, or why it can't be converted.
|
||||
///
|
||||
/// Our polylines keep no spline frame: PEDIT Spline replaces the vertices with points on the
|
||||
/// curve. Those points become the fit points, so the spline follows the smoothed shape; a
|
||||
/// closed polyline gives a closed (periodic) spline. Arc segments (PEDIT Fit, or drawn arcs) are
|
||||
/// refused: fit points would lose the arcs.
|
||||
pub(super) fn polyline_spline(k: &EntityKind, opts: FitOptions) -> std::result::Result<Spline, &'static str> {
|
||||
let (pts, closed): (Vec<Vec2>, bool) = match k {
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
if pl.vertices.iter().any(|v| v.bulge.abs() > 1e-12) {
|
||||
return Err("it has arc segments");
|
||||
}
|
||||
(pl.vertices.iter().map(|v| v.p).collect(), pl.closed)
|
||||
}
|
||||
EntityKind::Polyline3d(pl) => {
|
||||
let z0 = pl.points.first().map(|p| p.z).unwrap_or(0.0);
|
||||
if pl.points.iter().any(|p| (p.z - z0).abs() > 1e-9) {
|
||||
return Err("it is not planar");
|
||||
}
|
||||
(pl.points.iter().map(|p| p.xy()).collect(), pl.closed)
|
||||
}
|
||||
_ => return Err("it is not a polyline"),
|
||||
};
|
||||
let mut pts = distinct_points(&pts);
|
||||
if closed && pts.len() > 2 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 2 || pts.len() > curves::MAX_GEN {
|
||||
return Err("it has too few or too many vertices");
|
||||
}
|
||||
if closed && pts.len() < 3 {
|
||||
return Err("it has too few vertices to close");
|
||||
}
|
||||
Ok(Spline::fit_with(&pts, closed, opts))
|
||||
}
|
||||
|
||||
/// Replace each convertible polyline in `hs` by its spline (same handle, layer and properties);
|
||||
/// reports the others. Returns how many were converted.
|
||||
pub(super) fn convert_objects(s: &mut Session, hs: &[Handle], opts: FitOptions) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
let e = curves::entity(s, *h)?;
|
||||
if curves::is_locked(s, *h) {
|
||||
s.echo("1 object was on a locked layer.");
|
||||
continue;
|
||||
}
|
||||
match polyline_spline(&e.kind, opts) {
|
||||
Ok(sp) => {
|
||||
s.doc_mut()?.modify_entity(*h, |e| e.kind = EntityKind::Spline(sp))?;
|
||||
n += 1;
|
||||
}
|
||||
Err(why) => s.echo(format!("Object not converted: {why}.")),
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Fit options from the JSON form: `knots` ("chord" | "sqrt" | "uniform"), `startTangent` and
|
||||
/// `endTangent` (directions [dx, dy]), `tolerance` (≥ 0).
|
||||
pub(super) fn json_options(s: &Session, p: &Value) -> Result<FitOptions> {
|
||||
let knots = match p.get("knots") {
|
||||
None | Some(Value::Null) => current_knots(s),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
.and_then(|t| KnotParam::parse(&t))
|
||||
.ok_or_else(|| bad("spline", "`knots` must be \"chord\", \"sqrt\" or \"uniform\""))?,
|
||||
};
|
||||
let tangent = |key: &str| -> Result<Option<Vec2>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => point_param(p, key)
|
||||
.filter(|d| d.len() > 1e-12)
|
||||
.map(|d| Some(d.normalized()))
|
||||
.ok_or_else(|| bad("spline", format!("`{key}` must be a non-zero direction [dx, dy]"))),
|
||||
}
|
||||
};
|
||||
let tolerance = f64_or(p, "tolerance", 0.0);
|
||||
if tolerance < 0.0 {
|
||||
return Err(bad("spline", "`tolerance` must be zero or positive"));
|
||||
}
|
||||
Ok(FitOptions { knots, start_tangent: tangent("startTangent")?, end_tangent: tangent("endTangent")?, tolerance })
|
||||
}
|
||||
|
||||
/// `{object: true, handles?}`: convert polylines (or the selection) into splines.
|
||||
pub(super) fn run_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = match p.get("object") {
|
||||
Some(Value::Bool(true)) => targets(s, p)?,
|
||||
Some(v) => vec![curves::handle_value(v).ok_or_else(|| bad("spline", "`object` must be a handle or true"))?],
|
||||
None => return Err(bad("spline", "`object` is required")),
|
||||
};
|
||||
if hs.is_empty() {
|
||||
return Err(bad("spline", "no polyline to convert: give `object` a handle or select polylines"));
|
||||
}
|
||||
let opts = json_options(s, p)?;
|
||||
let mut converted = Vec::new();
|
||||
for h in &hs {
|
||||
let e = curves::entity(s, *h)?;
|
||||
if curves::is_locked(s, *h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let sp = polyline_spline(&e.kind, opts).map_err(|why| other(format!("Object {} not converted: {why}.", h.hex())))?;
|
||||
s.doc_mut()?.modify_entity(*h, |e| e.kind = EntityKind::Spline(sp))?;
|
||||
converted.push(h.hex());
|
||||
}
|
||||
Ok(json!({ "handles": converted }))
|
||||
}
|
||||
@@ -118,11 +118,12 @@ fn run_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rows > MAX_ROWS || cols > MAX_COLS || cols == 0 {
|
||||
return Err(bad("table", format!("rows must be ≤ {MAX_ROWS} and columns 1..={MAX_COLS}")));
|
||||
}
|
||||
let pos = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let row_height = size_param("table", p, "rowHeight", false)?;
|
||||
let col_width = size_param("table", p, "colWidth", false)?;
|
||||
let header: Option<Vec<String>> =
|
||||
p.get("header").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect());
|
||||
let st = style_of(s);
|
||||
let t = make_table(&st, at, rows, cols, pos("rowHeight"), pos("colWidth"), str_param(p, "title"), header, &cells);
|
||||
let t = make_table(&st, at, rows, cols, row_height, col_width, str_param(p, "title"), header, &cells);
|
||||
let h = s.add_entity(EntityKind::Table(t))?;
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! TRIM and EXTEND: the prompt sequence and the JSON forms. Quick and Standard modes
|
||||
//! (TRIMEXTENDMODE), cutting/boundary edges, Fence, Crossing, Project (PROJMODE), Edge (EDGEMODE),
|
||||
//! eRase and Undo. The geometry of one trim or extend is in modify.rs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_geom::{Bounds2, Line, Vec2, line_line};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::{line, lwpoly, rect_vertices};
|
||||
use super::modify::{Edges, extend, trim};
|
||||
use super::{bad, point_param, points_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most fence points the JSON form takes.
|
||||
const MAX_FENCE: usize = 10_000;
|
||||
|
||||
/// TRIMEXTENDMODE: 1 (default) Quick, 0 Standard.
|
||||
fn quick_mode(s: &Session) -> bool {
|
||||
s.doc().map(|d| d.header.i64("TRIMEXTENDMODE", 1) != 0).unwrap_or(true)
|
||||
}
|
||||
/// EDGEMODE: 1 extends cutting and boundary edges virtually (Standard mode).
|
||||
fn edge_mode(s: &Session) -> bool {
|
||||
s.doc().map(|d| d.header.i64("EDGEMODE", 0) != 0).unwrap_or(false)
|
||||
}
|
||||
/// PROJMODE: 0 None, 1 UCS (default), 2 View. Drawings are 2D (every object lies in the XY
|
||||
/// plane), so the three give the same result; the setting is kept for scripts and the DWG.
|
||||
fn proj_mode(s: &Session) -> i64 {
|
||||
s.doc().map(|d| d.header.i64("PROJMODE", 1)).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Whether a trim error says the object meets no cutting edge (a Quick-mode trim then erases it).
|
||||
fn misses_edges(e: &EngineError) -> bool {
|
||||
e.to_string().contains("intersect")
|
||||
}
|
||||
|
||||
/// Trim or extend `h` at `at`. A Quick-mode (`quick`) trim of an object no cutting edge crosses
|
||||
/// erases it. Returns the handles that replace `h` (none when it was erased).
|
||||
fn apply(s: &mut Session, h: Handle, at: Vec2, extending: bool, edges: Edges, quick: bool) -> Result<Vec<Handle>> {
|
||||
if extending {
|
||||
extend(s, h, at, edges)?;
|
||||
return Ok(vec![h]);
|
||||
}
|
||||
match trim(s, h, at, edges) {
|
||||
Err(e) if quick && misses_edges(&e) => {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim (or extend) every object the fence `pts` crosses, at each crossing (extend: at the first).
|
||||
/// Returns how many trims or extends were made.
|
||||
fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bool) -> Result<usize> {
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let fb = Bounds2::from_points(pts.iter().copied());
|
||||
let tol = crate::select::window_tol(&fb);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_fence(d, &space, pts) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let polys = crate::select::hit_polylines(d, e, tol);
|
||||
let mut xs: Vec<Vec2> = Vec::new();
|
||||
for f in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (f.first(), f.get(1)) else { continue };
|
||||
let fl = Line::new(*a, *b);
|
||||
// Crossings along this fence segment, in fence order.
|
||||
let mut here: Vec<Vec2> = polys
|
||||
.iter()
|
||||
.flat_map(|pl| pl.windows(2).filter_map(|w| line_line(&fl, &Line::new(*w.first()?, *w.get(1)?))))
|
||||
.filter(|x| !xs.iter().any(|y| y.near(*x, tol)))
|
||||
.collect();
|
||||
here.sort_by(|x, y| x.dist(*a).total_cmp(&y.dist(*a)));
|
||||
here.dedup_by(|x, y| x.near(*y, tol));
|
||||
xs.extend(here);
|
||||
}
|
||||
work.push((h, xs));
|
||||
}
|
||||
run_picks(s, work, extending, edges, quick, tol)
|
||||
}
|
||||
|
||||
/// Trim (or extend) every object a crossing window from `a` to `b` touches, picked at its point
|
||||
/// in the window nearest the first corner `a`.
|
||||
fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, quick: bool) -> Result<usize> {
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let bx = Bounds2::new(a, b);
|
||||
let tol = crate::select::window_tol(&bx);
|
||||
let sides = [
|
||||
Line::new(bx.min, Vec2::new(bx.max.x, bx.min.y)),
|
||||
Line::new(Vec2::new(bx.max.x, bx.min.y), bx.max),
|
||||
Line::new(bx.max, Vec2::new(bx.min.x, bx.max.y)),
|
||||
Line::new(Vec2::new(bx.min.x, bx.max.y), bx.min),
|
||||
];
|
||||
let inside = |p: Vec2| bx.expand(tol).contains(p);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_window(d, &space, bx, true) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let mut best: Option<Vec2> = None;
|
||||
for pl in crate::select::hit_polylines(d, e, tol) {
|
||||
for w in pl.windows(2) {
|
||||
let (Some(p), Some(q)) = (w.first(), w.get(1)) else { continue };
|
||||
let seg = Line::new(*p, *q);
|
||||
let cands = [*p, *q, seg.closest(a)].into_iter().chain(sides.iter().filter_map(|sd| line_line(sd, &seg)));
|
||||
for c in cands.filter(|c| inside(*c)) {
|
||||
if best.is_none_or(|b| c.dist(a) < b.dist(a)) {
|
||||
best = Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = best {
|
||||
work.push((h, vec![p]));
|
||||
}
|
||||
}
|
||||
run_picks(s, work, extending, edges, quick, tol)
|
||||
}
|
||||
|
||||
/// Trim (or extend) each object at its pick points. A trim replaces the object with pieces, so
|
||||
/// later picks go to the piece within `tol` of the point (none: that part is already gone).
|
||||
fn run_picks(s: &mut Session, work: Vec<(Handle, Vec<Vec2>)>, extending: bool, edges: Edges, quick: bool, tol: f64) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
let mut last_err = None;
|
||||
for (h, picks) in work {
|
||||
let mut live = vec![h];
|
||||
for x in picks {
|
||||
let target = {
|
||||
let d = s.doc()?;
|
||||
live.iter()
|
||||
.filter_map(|l| d.entity(*l).map(|e| (*l, crate::select::entity_distance(d, e, x, tol))))
|
||||
.filter(|(_, dist)| *dist <= 3.0 * tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(l, _)| l)
|
||||
};
|
||||
let Some(t) = target else { continue };
|
||||
match apply(s, t, x, extending, edges, quick) {
|
||||
Ok(new) => {
|
||||
live.retain(|l| *l != t);
|
||||
live.extend(new);
|
||||
n += 1;
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
if extending {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match (n, last_err) {
|
||||
(0, Some(e)) => Err(e),
|
||||
(0, None) => Err(EngineError::Other("No objects found.".into())),
|
||||
_ => Ok(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON forms' `edgeMode`: "extend" / "none" (or true / false).
|
||||
fn edge_mode_param(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("edgeMode") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(Value::String(t)) => match t.trim().to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"extend" => Ok(true),
|
||||
"none" | "noextend" => Ok(false),
|
||||
_ => Err(bad(cmd, "`edgeMode` must be \"extend\" or \"none\"")),
|
||||
},
|
||||
Some(_) => Err(bad(cmd, "`edgeMode` must be \"extend\" or \"none\"")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value, extending: bool) -> Result<Value> {
|
||||
let cmd = if extending { "extend" } else { "trim" };
|
||||
let only: Option<Vec<Handle>> =
|
||||
p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect());
|
||||
let edges = Edges { only: only.as_deref(), implied: edge_mode_param(cmd, p)? };
|
||||
let quick = match p.get("mode") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str().map(|m| m.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("quick") => true,
|
||||
Some("standard") => false,
|
||||
_ => return Err(bad(cmd, "`mode` must be \"quick\" or \"standard\"")),
|
||||
},
|
||||
};
|
||||
let key = if extending { "extended" } else { "trimmed" };
|
||||
if p.get("fence").is_some_and(|v| !v.is_null()) {
|
||||
let pts = points_param(p, "fence")
|
||||
.filter(|v| (2..=MAX_FENCE).contains(&v.len()))
|
||||
.ok_or_else(|| bad(cmd, "`fence` needs two or more points [[x,y], ...]"))?;
|
||||
let n = fence(s, &pts, extending, edges, quick)?;
|
||||
return Ok(json!({ key: n }));
|
||||
}
|
||||
if p.get("crossing").is_some_and(|v| !v.is_null()) {
|
||||
let c = points_param(p, "crossing").filter(|v| v.len() == 2).ok_or_else(|| bad(cmd, "`crossing` needs two corners [[x,y], [x,y]]"))?;
|
||||
let (Some(a), Some(b)) = (c.first(), c.get(1)) else { return Err(bad(cmd, "`crossing` needs two corners")) };
|
||||
let n = crossing(s, *a, *b, extending, edges, quick)?;
|
||||
return Ok(json!({ key: n }));
|
||||
}
|
||||
let h = super::targets(s, p)?.first().copied().ok_or_else(|| bad(cmd, "`handle` is required"))?;
|
||||
let pick = point_param(p, "pick").ok_or_else(|| bad(cmd, "`pick` ([x, y]) is required"))?;
|
||||
let r = apply(s, h, pick, extending, edges, quick)?;
|
||||
if extending { Ok(Value::Null) } else { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() })) }
|
||||
}
|
||||
|
||||
pub(crate) fn run_trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run(s, p, false)
|
||||
}
|
||||
|
||||
pub(crate) fn run_extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run(s, p, true)
|
||||
}
|
||||
|
||||
// ---------------- the prompt sequence ----------------
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Stage {
|
||||
/// "Select object to trim or shift-select to extend".
|
||||
Pick,
|
||||
/// "Select objects or <select all>": the cutting/boundary edges picked so far.
|
||||
Edges(Vec<Handle>),
|
||||
/// Fence points so far.
|
||||
Fence(Vec<Vec2>),
|
||||
/// A crossing window: its first corner once picked.
|
||||
Crossing(Option<Vec2>),
|
||||
Mode,
|
||||
Project,
|
||||
Edge,
|
||||
/// "Select objects to erase or <exit>": the objects picked so far.
|
||||
Erase(Vec<Handle>),
|
||||
}
|
||||
|
||||
pub(crate) struct TrimM {
|
||||
extend: bool,
|
||||
/// Cutting/boundary edges; `None`: all objects.
|
||||
edges: Option<Vec<Handle>>,
|
||||
stage: Stage,
|
||||
/// The drawing before each trim, extend or erase, for Undo.
|
||||
undo: Vec<Arc<Drawing>>,
|
||||
}
|
||||
|
||||
impl TrimM {
|
||||
pub(crate) fn new(extend: bool) -> Self {
|
||||
TrimM { extend, edges: None, stage: Stage::Pick, undo: Vec::new() }
|
||||
}
|
||||
fn edges(&self, quick: bool, implied: bool) -> Edges<'_> {
|
||||
Edges { only: self.edges.as_deref(), implied: !quick && implied }
|
||||
}
|
||||
fn select_edges(&mut self, s: &mut Session) {
|
||||
s.echo(if self.extend { "Select boundary edges ..." } else { "Select cutting edges ..." });
|
||||
s.set_selection(Vec::new());
|
||||
self.stage = Stage::Edges(Vec::new());
|
||||
}
|
||||
/// Run `f` as one undoable step of the command; a step that changed nothing records none.
|
||||
fn step(&mut self, s: &mut Session, f: impl FnOnce(&mut Self, &mut Session) -> Result<()>) -> Result<()> {
|
||||
let before = s.state()?.doc.clone();
|
||||
let r = f(self, s);
|
||||
if !Arc::ptr_eq(&before, &s.state()?.doc) {
|
||||
self.undo.push(before);
|
||||
}
|
||||
r
|
||||
}
|
||||
fn option(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let word = match &i {
|
||||
Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(),
|
||||
Input::Enter => String::new(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let stage = self.stage.clone();
|
||||
let invalid = || EngineError::Other("Invalid option keyword.".into());
|
||||
match stage {
|
||||
Stage::Mode => {
|
||||
let quick = match word.as_str() {
|
||||
"" => quick_mode(s),
|
||||
w if w.starts_with('q') => true,
|
||||
w if w.starts_with('s') => false,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
s.doc_mut()?.header.set_i64("TRIMEXTENDMODE", i64::from(quick));
|
||||
if quick {
|
||||
self.edges = None;
|
||||
self.stage = Stage::Pick;
|
||||
} else {
|
||||
self.select_edges(s);
|
||||
}
|
||||
}
|
||||
Stage::Project => {
|
||||
let v = match word.as_str() {
|
||||
"" => proj_mode(s),
|
||||
w if w.starts_with('n') => 0,
|
||||
w if w.starts_with('u') => 1,
|
||||
w if w.starts_with('v') => 2,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
s.doc_mut()?.header.set_i64("PROJMODE", v);
|
||||
self.stage = Stage::Pick;
|
||||
}
|
||||
Stage::Edge => {
|
||||
let v = match word.as_str() {
|
||||
"" => edge_mode(s),
|
||||
w if w.starts_with('e') => true,
|
||||
w if w.starts_with('n') => false,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
s.doc_mut()?.header.set_i64("EDGEMODE", i64::from(v));
|
||||
self.stage = Stage::Pick;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for TrimM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.extend { "EXTEND" } else { "TRIM" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
let quick = quick_mode(s);
|
||||
let proj = match proj_mode(s) {
|
||||
0 => "None",
|
||||
2 => "View",
|
||||
_ => "UCS",
|
||||
};
|
||||
s.echo(format!(
|
||||
"Current settings: Projection={proj}, Edge={}, Mode={}",
|
||||
if edge_mode(s) { "Extend" } else { "None" },
|
||||
if quick { "Quick" } else { "Standard" }
|
||||
));
|
||||
if !quick {
|
||||
self.select_edges(s);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match &self.stage {
|
||||
Stage::Pick => {
|
||||
let (msg, first) = if self.extend {
|
||||
("Select object to extend or shift-select to trim", "Boundary edges")
|
||||
} else {
|
||||
("Select object to trim or shift-select to extend", "cuTting edges")
|
||||
};
|
||||
let mut kw = vec![first, "Fence", "Crossing", "mOde", "Project"];
|
||||
if !quick_mode(s) {
|
||||
kw.push("Edge");
|
||||
}
|
||||
if !self.extend {
|
||||
kw.push("eRase");
|
||||
}
|
||||
kw.push("Undo");
|
||||
Prompt::new(msg, Accept::POINT).kw(&kw)
|
||||
}
|
||||
Stage::Edges(_) => Prompt::new("Select objects or", Accept::SELECT).default("select all"),
|
||||
Stage::Erase(_) => Prompt::new("Select objects to erase or", Accept::SELECT).default("exit"),
|
||||
Stage::Fence(pts) => match pts.last() {
|
||||
None => Prompt::new("Specify first fence point", Accept::POINT),
|
||||
Some(l) => Prompt::new("Specify next fence point", Accept::POINT).kw(&["Undo"]).base(*l),
|
||||
},
|
||||
Stage::Crossing(None) => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Stage::Crossing(Some(_)) => Prompt::new("Specify opposite corner", Accept::POINT),
|
||||
Stage::Mode => {
|
||||
let msg = if self.extend { "Enter an extend mode option" } else { "Enter a trim mode option" };
|
||||
Prompt::new(msg, Accept::TEXT).kw(&["Quick", "Standard"]).default(if quick_mode(s) { "Quick" } else { "Standard" })
|
||||
}
|
||||
Stage::Project => {
|
||||
let cur = match proj_mode(s) {
|
||||
0 => "None",
|
||||
2 => "View",
|
||||
_ => "Ucs",
|
||||
};
|
||||
Prompt::new("Enter a projection option", Accept::TEXT).kw(&["None", "Ucs", "View"]).default(cur)
|
||||
}
|
||||
Stage::Edge => Prompt::new("Enter an implied edge extension mode", Accept::TEXT).kw(&["Extend", "No extend"]).default(if edge_mode(s) {
|
||||
"Extend"
|
||||
} else {
|
||||
"No extend"
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let quick = quick_mode(s);
|
||||
let implied = edge_mode(s);
|
||||
let extending = self.extend;
|
||||
match self.stage.clone() {
|
||||
Stage::Mode | Stage::Project | Stage::Edge => self.option(s, i),
|
||||
Stage::Edges(mut picked) => {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
for h in hs {
|
||||
if !picked.contains(&h) {
|
||||
picked.push(h);
|
||||
}
|
||||
}
|
||||
s.set_selection(picked.clone());
|
||||
self.stage = Stage::Edges(picked);
|
||||
}
|
||||
Input::Enter => {
|
||||
s.set_selection(Vec::new());
|
||||
self.edges = if picked.is_empty() { None } else { Some(picked) };
|
||||
self.stage = Stage::Pick;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Erase(mut picked) => {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
for h in hs {
|
||||
if !picked.contains(&h) {
|
||||
picked.push(h);
|
||||
}
|
||||
}
|
||||
s.set_selection(picked.clone());
|
||||
self.stage = Stage::Erase(picked);
|
||||
}
|
||||
Input::Enter => {
|
||||
s.set_selection(Vec::new());
|
||||
self.step(s, |_, s| {
|
||||
let d = s.doc_mut()?;
|
||||
for h in &picked {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
self.stage = Stage::Pick;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Fence(mut pts) => {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
pts.push(p);
|
||||
self.stage = Stage::Fence(pts);
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
pts.pop();
|
||||
self.stage = Stage::Fence(pts);
|
||||
}
|
||||
Input::Enter => {
|
||||
self.stage = Stage::Pick;
|
||||
if pts.len() >= 2 {
|
||||
let r = self.step(s, |m, s| fence(s, &pts, extending, m.edges(quick, implied), quick).map(|_| ()));
|
||||
if let Err(e) = r {
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Crossing(first) => {
|
||||
match (first, i) {
|
||||
(None, Input::Point(p)) => self.stage = Stage::Crossing(Some(p)),
|
||||
(Some(a), Input::Point(b)) => {
|
||||
self.stage = Stage::Pick;
|
||||
let r = self.step(s, |m, s| crossing(s, a, b, extending, m.edges(quick, implied), quick).map(|_| ()));
|
||||
if let Err(e) = r {
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
(_, Input::Enter) => self.stage = Stage::Pick,
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Pick => 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 {
|
||||
// A pick in empty space opens a crossing window there.
|
||||
self.stage = Stage::Crossing(Some(p));
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let r = self.step(s, |m, s| apply(s, h, p, extending, m.edges(quick, implied), quick).map(|_| ()));
|
||||
if let Err(e) = r {
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"cuTting edges" | "Boundary edges" => self.select_edges(s),
|
||||
"Fence" => self.stage = Stage::Fence(Vec::new()),
|
||||
"Crossing" => self.stage = Stage::Crossing(None),
|
||||
"mOde" => self.stage = Stage::Mode,
|
||||
"Project" => self.stage = Stage::Project,
|
||||
"Edge" => self.stage = Stage::Edge,
|
||||
"eRase" => {
|
||||
s.set_selection(Vec::new());
|
||||
self.stage = Stage::Erase(Vec::new());
|
||||
}
|
||||
"Undo" => match self.undo.pop() {
|
||||
Some(doc) => {
|
||||
// Settings changed meanwhile stay.
|
||||
let header = s.doc()?.header.clone();
|
||||
s.state_mut()?.doc = doc;
|
||||
s.doc_mut()?.header = header;
|
||||
}
|
||||
None => s.echo("Nothing to undo."),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match &self.stage {
|
||||
Stage::Fence(pts) if !pts.is_empty() => pts
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(std::iter::once(c))
|
||||
.collect::<Vec<_>>()
|
||||
.windows(2)
|
||||
.filter_map(|w| Some(line(*w.first()?, *w.get(1)?)))
|
||||
.collect(),
|
||||
Stage::Crossing(Some(a)) => vec![lwpoly(rect_vertices(*a, c), true)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
-32
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("zoom", "Zoom", run_zoom)
|
||||
.menu(&["View", "Zoom", "Realtime"])
|
||||
.alias(&["z"])
|
||||
.params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?}")
|
||||
.params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?, xp?: bool (scale relative to paper space: in a viewport, `factor` paper units per model unit), limits?: bool (scale relative to the limits)} (center: `height`, or `factor` with `xp`/`limits`)")
|
||||
.noundo()
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ZoomM::default()))),
|
||||
@@ -95,9 +95,10 @@ fn fit(s: &mut Session, b: Bounds2) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let (w, h) = s.viewport_px;
|
||||
let aspect = w / h.max(1.0);
|
||||
let st = s.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0));
|
||||
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
|
||||
s.state_mut()?.set_view(View { center: b.center(), height });
|
||||
st.set_zoom_frame(View { center: b.center(), height });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -106,10 +107,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match mode.as_str() {
|
||||
"extents" | "e" => s.zoom_extents()?,
|
||||
"all" | "a" => {
|
||||
let d = s.doc()?;
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
let b = ext_bounds(s)?.union(&Bounds2::new(lo, hi));
|
||||
let b = ext_bounds(s)?.union(&s.zoom_limits()?);
|
||||
fit(s, b.expand(b.height().max(b.width()) * 0.02))?;
|
||||
}
|
||||
"window" | "w" => {
|
||||
@@ -128,23 +126,19 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"in" | "out" => {
|
||||
let f = if mode == "in" { 2.0 } else { 0.5 };
|
||||
let st = s.state_mut()?;
|
||||
let v = st.view();
|
||||
st.set_view(View { center: v.center, height: v.height / f });
|
||||
let v = st.zoom_frame();
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height / f });
|
||||
}
|
||||
"center" | "c" => {
|
||||
let c = point_req("zoom", p, "center")?;
|
||||
let st = s.state_mut()?;
|
||||
let h = f64_or(p, "height", st.view().height);
|
||||
st.set_view(View { center: c, height: h.max(1e-9) });
|
||||
let cur = s.state()?.zoom_frame().height;
|
||||
let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) };
|
||||
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) });
|
||||
}
|
||||
"scale" | "s" => {
|
||||
let f = f64_req("zoom", p, "factor")?;
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
let v = st.view();
|
||||
st.set_view(View { center: v.center, height: v.height / f });
|
||||
let v = s.state()?.zoom_frame();
|
||||
let height = factor_height(s, p, v.height)?;
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
|
||||
}
|
||||
"object" | "o" => {
|
||||
let hs = targets(s, p)?;
|
||||
@@ -158,6 +152,54 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The view height for a zoom `factor`: relative to the current height `cur` (nX), to paper space
|
||||
/// with `xp` (nXP; in a viewport `factor` paper units per model unit, elsewhere like nX), or to
|
||||
/// the limits with `limits` (a plain n: 1 fits the limits).
|
||||
fn factor_height(s: &Session, p: &Value, cur: f64) -> Result<f64> {
|
||||
let f = f64_req("zoom", p, "factor")?;
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
let st = s.state()?;
|
||||
if bool_or(p, "xp", false)
|
||||
&& let Some(vp) = st.zoom_viewport()
|
||||
{
|
||||
return Ok(vp.height / f);
|
||||
}
|
||||
if bool_or(p, "limits", false) {
|
||||
let lim = s.zoom_limits()?;
|
||||
let (w, h) = s.viewport_px;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-6);
|
||||
return Ok(lim.height().max(lim.width() / aspect).max(1e-9) / f);
|
||||
}
|
||||
Ok(cur / f)
|
||||
}
|
||||
|
||||
/// A typed zoom factor: `n` (relative to the limits), `nX` (to the current view) or `nXP` (to
|
||||
/// paper space), as `zoom` scale parameters.
|
||||
fn typed_factor(t: &str) -> Option<Value> {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let (num, rel) = if let Some(n) = tl.strip_suffix("xp") {
|
||||
(n, "xp")
|
||||
} else if let Some(n) = tl.strip_suffix('x') {
|
||||
(n, "x")
|
||||
} else {
|
||||
(tl.as_str(), "limits")
|
||||
};
|
||||
let f = number(num.trim()).filter(|f| *f > 0.0)?;
|
||||
Some(json!({ "factor": f, "xp": rel == "xp", "limits": rel == "limits" }))
|
||||
}
|
||||
|
||||
fn with_mode(mut p: Value, mode: &str, center: Option<Vec2>) -> Value {
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("mode".into(), json!(mode));
|
||||
if let Some(c) = center {
|
||||
o.insert("center".into(), json!([c.x, c.y]));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn run_zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
zoom(s, if p.is_null() { &Value::Null } else { p })
|
||||
}
|
||||
@@ -226,13 +268,33 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
struct ZoomM {
|
||||
window: bool,
|
||||
first: Option<Vec2>,
|
||||
stage: ZoomStage,
|
||||
}
|
||||
|
||||
/// The Center and Scale options' own prompts.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum ZoomStage {
|
||||
#[default]
|
||||
Main,
|
||||
Center,
|
||||
CenterHeight(Vec2),
|
||||
Scale,
|
||||
}
|
||||
|
||||
impl Interactive for ZoomM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ZOOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
ZoomStage::Main => {}
|
||||
ZoomStage::Center => return Prompt::new("Specify center point", Accept::POINT),
|
||||
ZoomStage::CenterHeight(_) => {
|
||||
let h = s.state().map(|st| st.zoom_frame().height).unwrap_or(1.0);
|
||||
return Prompt::new("Enter magnification or height", Accept::NUMBER).default(format!("{h:.4}"));
|
||||
}
|
||||
ZoomStage::Scale => return Prompt::new("Enter a scale factor (nX or nXP)", Accept::NUMBER),
|
||||
}
|
||||
match (self.window, self.first) {
|
||||
(false, None) => Prompt::new("Specify corner of window, enter a scale factor (nX or nXP)", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["All", "Center", "Dynamic", "Extents", "Previous", "Scale", "Window", "Object"])
|
||||
@@ -242,18 +304,63 @@ impl Interactive for ZoomM {
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let bad_factor = || crate::EngineError::Other("Requires a scale factor (n, nX or nXP).".into());
|
||||
match (self.stage, &i) {
|
||||
(_, Input::Cancel) | (ZoomStage::Main, _) => {}
|
||||
(ZoomStage::Center, Input::Point(c)) => {
|
||||
self.stage = ZoomStage::CenterHeight(*c);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
(ZoomStage::Center, Input::Enter) => return Ok(Step::Done),
|
||||
(ZoomStage::Center, _) => return Err(crate::EngineError::Other("Requires a point.".into())),
|
||||
(ZoomStage::CenterHeight(c), Input::Enter) => {
|
||||
zoom(s, &json!({ "mode": "center", "center": [c.x, c.y] }))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::CenterHeight(c), Input::Text(t)) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
// A plain number is the new view height; nX / nXP a magnification.
|
||||
let p = if tl.ends_with('x') || tl.ends_with("xp") {
|
||||
typed_factor(&tl).ok_or_else(bad_factor)?
|
||||
} else {
|
||||
json!({ "height": number(&tl).filter(|h| *h > 0.0).ok_or_else(bad_factor)? })
|
||||
};
|
||||
zoom(s, &with_mode(p, "center", Some(c)))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Scale, Input::Text(t)) => {
|
||||
zoom(s, &with_mode(typed_factor(t).ok_or_else(bad_factor)?, "scale", None))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Scale, Input::Enter) => return Ok(Step::Done),
|
||||
(_, _) => return Err(bad_factor()),
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
let mode = match k.as_str() {
|
||||
"All" => "all",
|
||||
"Extents" => "extents",
|
||||
"Previous" => "previous",
|
||||
"Dynamic" => {
|
||||
// Dynamic zoom drags a view box; without one it asks for a window.
|
||||
s.echo("Dynamic zoom: specify the new view as a window.");
|
||||
self.window = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Window" => {
|
||||
self.window = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Center" => {
|
||||
self.stage = ZoomStage::Center;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Scale" => {
|
||||
self.stage = ZoomStage::Scale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Object" => "object",
|
||||
_ => "extents",
|
||||
_ => return Err(crate::EngineError::Other("Invalid option keyword.".into())),
|
||||
};
|
||||
zoom(s, &json!({ "mode": mode }))?;
|
||||
Ok(Step::Done)
|
||||
@@ -269,17 +376,9 @@ impl Interactive for ZoomM {
|
||||
}
|
||||
},
|
||||
Input::Text(t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let num = tl.trim_end_matches("xp").trim_end_matches('x');
|
||||
let f = number(num)
|
||||
.filter(|f| *f > 0.0)
|
||||
.ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?;
|
||||
if tl.ends_with('x') {
|
||||
zoom(s, &json!({ "mode": "scale", "factor": f }))?;
|
||||
} else {
|
||||
// Plain number: scale relative to limits (approximation: like nX).
|
||||
zoom(s, &json!({ "mode": "scale", "factor": f }))?;
|
||||
}
|
||||
// nX: relative to the current view; nXP: to paper space; n: to the limits.
|
||||
let p = typed_factor(&t).ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?;
|
||||
zoom(s, &with_mode(p, "scale", None))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Edits whose result has coordinates too large to represent (NaN or infinite) are refused.
|
||||
//!
|
||||
//! Finite inputs can still overflow: moving by `[1e308, 1e308]` twice, scaling by a huge factor or
|
||||
//! offsetting by a huge distance. Infinite coordinates break extents, inspection and rendering, and
|
||||
//! are silently changed by a DXF save and reopen, so the command fails and the drawing stays as it
|
||||
//! was. Only objects the command changed are checked: every number in their serialized fields, and
|
||||
//! their extents.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, entity_bounds};
|
||||
use serde::Serialize;
|
||||
use serde::ser;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
impl Session {
|
||||
/// After command `cmd` changed the drawing from `before` (the document `uid`, or the active
|
||||
/// one): when an object it changed has NaN or infinite numbers, put the drawing and selection
|
||||
/// back and return the error to report.
|
||||
pub(crate) fn refuse_non_finite(&mut self, uid: Option<u64>, before: &Arc<Drawing>, selection: &[Handle], cmd: &str) -> crate::Result<()> {
|
||||
let st = match uid {
|
||||
Some(uid) => self.docs.iter_mut().find(|d| d.uid == uid),
|
||||
None => self.state_mut().ok(),
|
||||
};
|
||||
let Some(st) = st.filter(|st| !Arc::ptr_eq(before, &st.doc)) else { return Ok(()) };
|
||||
let Some(h) = first_non_finite(before, &st.doc) else { return Ok(()) };
|
||||
st.doc = before.clone();
|
||||
st.selection = selection.to_vec();
|
||||
Err(EngineError::BadParams {
|
||||
cmd: cmd.into(),
|
||||
msg: format!("the result is too large: object {} would get infinite or undefined coordinates. Nothing was changed", h.hex()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The first object changed between `before` and `after` that has a NaN or infinite number, or
|
||||
/// whose extents overflow.
|
||||
pub(crate) fn first_non_finite(before: &Drawing, after: &Drawing) -> Option<Handle> {
|
||||
let layouts = after.layouts.iter().map(|l| (&l.entities, before.layout(&l.name).map(|b| &b.entities)));
|
||||
let blocks = after
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|(name, b)| before.blocks.get(*name).is_none_or(|old| !Arc::ptr_eq(old, b)))
|
||||
.map(|(name, b)| (&b.entities, before.blocks.get(name).map(|old| &old.entities)));
|
||||
std::iter::once((&after.model, Some(&before.model))).chain(layouts).chain(blocks).find_map(|(a, b)| changed_non_finite(after, b, a))
|
||||
}
|
||||
|
||||
fn changed_non_finite(d: &Drawing, before: Option<&EntityStore>, after: &EntityStore) -> Option<Handle> {
|
||||
if before.is_some_and(|b| b.same_as(after)) {
|
||||
return None;
|
||||
}
|
||||
// Chunks shared with `before` hold only unchanged objects.
|
||||
let shared: HashSet<usize> = before.map(|b| b.chunk_slices().map(|(key, _)| key).collect()).unwrap_or_default();
|
||||
let finite = |e: &Entity| {
|
||||
let b = entity_bounds(d, e, 0);
|
||||
is_finite(e) && (b.is_empty() || (b.min.is_finite() && b.max.is_finite()))
|
||||
};
|
||||
after
|
||||
.chunk_slices()
|
||||
.filter(|(key, _)| !shared.contains(key))
|
||||
.flat_map(|(_, items)| items.iter())
|
||||
.filter(|e| before.and_then(|b| b.get(e.handle)).is_none_or(|old| !Arc::ptr_eq(old, e)))
|
||||
.find(|e| !finite(e))
|
||||
.map(|e| e.handle)
|
||||
}
|
||||
|
||||
/// Whether every number in `v` is finite.
|
||||
pub(crate) fn is_finite<T: Serialize + ?Sized>(v: &T) -> bool {
|
||||
!matches!(v.serialize(Check), Err(Stop::NonFinite))
|
||||
}
|
||||
|
||||
/// Why the walk stopped: a non-finite number, or a value that can't be serialized (not ours to judge).
|
||||
#[derive(Debug)]
|
||||
enum Stop {
|
||||
NonFinite,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Stop {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Stop::NonFinite => "non-finite number",
|
||||
Stop::Other => "not serializable",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Stop {}
|
||||
|
||||
impl ser::Error for Stop {
|
||||
fn custom<T: std::fmt::Display>(_: T) -> Self {
|
||||
Stop::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// A serializer that only looks at floats.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Check;
|
||||
|
||||
type R = Result<(), Stop>;
|
||||
|
||||
macro_rules! ignore {
|
||||
($($name:ident: $t:ty),*) => { $(fn $name(self, _: $t) -> R { Ok(()) })* };
|
||||
}
|
||||
|
||||
impl ser::Serializer for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
type SerializeSeq = Self;
|
||||
type SerializeTuple = Self;
|
||||
type SerializeTupleStruct = Self;
|
||||
type SerializeTupleVariant = Self;
|
||||
type SerializeMap = Self;
|
||||
type SerializeStruct = Self;
|
||||
type SerializeStructVariant = Self;
|
||||
|
||||
ignore!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16, serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
|
||||
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32, serialize_u64: u64, serialize_u128: u128, serialize_char: char,
|
||||
serialize_str: &str, serialize_bytes: &[u8], serialize_unit_struct: &'static str);
|
||||
|
||||
fn serialize_f32(self, v: f32) -> R {
|
||||
if v.is_finite() { Ok(()) } else { Err(Stop::NonFinite) }
|
||||
}
|
||||
fn serialize_f64(self, v: f64) -> R {
|
||||
if v.is_finite() { Ok(()) } else { Err(Stop::NonFinite) }
|
||||
}
|
||||
fn serialize_none(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_some<T: Serialize + ?Sized>(self, v: &T) -> R {
|
||||
v.serialize(self)
|
||||
}
|
||||
fn serialize_unit(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_variant(self, _: &'static str, _: u32, _: &'static str) -> R {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_newtype_struct<T: Serialize + ?Sized>(self, _: &'static str, v: &T) -> R {
|
||||
v.serialize(self)
|
||||
}
|
||||
fn serialize_newtype_variant<T: Serialize + ?Sized>(self, _: &'static str, _: u32, _: &'static str, v: &T) -> R {
|
||||
v.serialize(self)
|
||||
}
|
||||
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple(self, _: usize) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_map(self, _: Option<usize>) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result<Self, Stop> {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeSeq for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_element<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeTuple for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_element<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeTupleStruct for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_field<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeTupleVariant for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_field<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeMap for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_key<T: Serialize + ?Sized>(&mut self, k: &T) -> R {
|
||||
k.serialize(*self)
|
||||
}
|
||||
fn serialize_value<T: Serialize + ?Sized>(&mut self, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeStruct for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_field<T: Serialize + ?Sized>(&mut self, _: &'static str, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ser::SerializeStructVariant for Check {
|
||||
type Ok = ();
|
||||
type Error = Stop;
|
||||
fn serialize_field<T: Serialize + ?Sized>(&mut self, _: &'static str, v: &T) -> R {
|
||||
v.serialize(*self)
|
||||
}
|
||||
fn end(self) -> R {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_nan_and_infinity_anywhere() {
|
||||
assert!(is_finite(&(1.0, vec![Some(2.0f32)], "inf")));
|
||||
assert!(!is_finite(&vec![(0.0, f64::INFINITY)]));
|
||||
assert!(!is_finite(&Some(f32::NAN)));
|
||||
let mut m = std::collections::BTreeMap::new();
|
||||
m.insert("x", f64::NEG_INFINITY);
|
||||
assert!(!is_finite(&m));
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -5,7 +5,7 @@
|
||||
//! undoable and reachable from scripts, the control channel and MCP.
|
||||
|
||||
use cadcraft_doc::{DimKind, EntityKind, Handle, LwPolyline};
|
||||
use cadcraft_geom::{Arc, EPS, Mat3, Polyline, Spline, Vec2, Vec3, norm_angle};
|
||||
use cadcraft_geom::{Arc, EPS, Mat3, Polyline, Vec2, Vec3, norm_angle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -181,10 +181,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
|
||||
} else {
|
||||
let mut fit = sp.fit.clone();
|
||||
*fit.get_mut(index)? = to;
|
||||
let closed = sp.closed;
|
||||
let mut n = Spline::from_fit_points(&fit);
|
||||
n.closed = closed;
|
||||
*sp = n;
|
||||
*sp = sp.refit(&fit, sp.closed);
|
||||
}
|
||||
}
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => {
|
||||
@@ -241,13 +238,17 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
|
||||
}
|
||||
}
|
||||
if !done {
|
||||
// The landing grip moves the landing and its text.
|
||||
// The landing grip moves the landing and its content.
|
||||
let ld = m.landing.xy() + delta;
|
||||
set_xy(&mut m.landing, ld);
|
||||
if let Some(t) = &mut m.text {
|
||||
let q = t.insert.xy() + delta;
|
||||
set_xy(&mut t.insert, q);
|
||||
}
|
||||
if let Some(b) = &mut m.block {
|
||||
let q = b.insert.xy() + delta;
|
||||
set_xy(&mut b.insert, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Solid(s) | EntityKind::Trace(s) => set_xy(s.corners.get_mut(index)?, to),
|
||||
@@ -294,21 +295,20 @@ pub fn mode_matrix(mode: GripMode, base: Vec2, to: Vec2) -> Option<Mat3> {
|
||||
|
||||
impl Session {
|
||||
/// Drag grip `grip_index` of `handle` to `new_point` in `mode` (one undo step).
|
||||
/// The base point of the rotate/scale/mirror modes is the grip itself.
|
||||
/// The base point of the rotate/scale/mirror modes is the grip itself. As in AutoCAD the edit
|
||||
/// covers the selection: a stretch drags the grips of other selected objects that sit on the
|
||||
/// same point, and the other modes transform every selected object.
|
||||
pub fn grip_edit(&mut self, handle: Handle, grip_index: usize, new_point: Vec2, mode: GripMode) -> Result<()> {
|
||||
let to = [new_point.x, new_point.y];
|
||||
let hs: Vec<String> = std::iter::once(handle).chain(self.selection().into_iter().filter(|h| *h != handle)).map(|h| h.hex()).collect();
|
||||
let r = match mode {
|
||||
GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "index": grip_index, "to": to })),
|
||||
GripMode::Move => self.execute("grip.move", &json!({ "handles": [handle.hex()], "index": grip_index, "to": to, "mode": "move" })),
|
||||
GripMode::Rotate => {
|
||||
self.execute("grip.rotate", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
|
||||
}
|
||||
GripMode::Scale => {
|
||||
self.execute("grip.scale", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
|
||||
}
|
||||
GripMode::Mirror => {
|
||||
self.execute("grip.mirror", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
|
||||
GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "handles": hs, "index": grip_index, "to": to })),
|
||||
GripMode::Move => {
|
||||
self.execute("grip.move", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to, "mode": "move" }))
|
||||
}
|
||||
GripMode::Rotate => self.execute("grip.rotate", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })),
|
||||
GripMode::Scale => self.execute("grip.scale", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })),
|
||||
GripMode::Mirror => self.execute("grip.mirror", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })),
|
||||
};
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Last-resort panic guard around interactive commands. `Session::execute` and interactive input
|
||||
//! already catch a panicking command; this wrapper does the same for the rest of a command
|
||||
//! machine: its factory, `begin` (the first step of `Session::start`), `prompt` and `preview`
|
||||
//! (called by the UI every frame). A panic ends the command with an internal error instead of
|
||||
//! taking down the app: from `begin` or `input` at once (the session restores the drawing), from
|
||||
//! `prompt` or `preview` (which can't end the command) at the next input, with a fallback prompt
|
||||
//! and no preview until then.
|
||||
|
||||
use std::any::Any;
|
||||
use std::cell::Cell;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use crate::cmd::Factory;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(crate) struct Guarded {
|
||||
inner: Box<dyn Interactive>,
|
||||
/// What panicked in `prompt` or `preview`; the command ends at the next input.
|
||||
broken: Cell<Option<&'static str>>,
|
||||
}
|
||||
|
||||
fn message(p: &(dyn Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
impl Guarded {
|
||||
/// Create the machine of command `id` and wrap it.
|
||||
pub(crate) fn create(id: &str, factory: Factory, s: &Session) -> Result<Box<dyn Interactive>> {
|
||||
let inner = catch_unwind(AssertUnwindSafe(|| factory(s))).map_err(|p| EngineError::Internal(id.into(), message(&*p)))??;
|
||||
Ok(Box::new(Guarded { inner, broken: Cell::new(None) }))
|
||||
}
|
||||
|
||||
fn internal(&self, p: &(dyn Any + Send)) -> EngineError {
|
||||
EngineError::Internal(self.inner.name().to_ascii_lowercase(), message(p))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for Guarded {
|
||||
fn name(&self) -> &'static str {
|
||||
self.inner.name()
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.broken.get().is_none() {
|
||||
match catch_unwind(AssertUnwindSafe(|| self.inner.prompt(s))) {
|
||||
// Pending point modifiers (FROM, M2P…) ask for their points in its place.
|
||||
Ok(p) => return s.overlay_prompt(p),
|
||||
Err(_) => self.broken.set(Some("panic in prompt")),
|
||||
}
|
||||
}
|
||||
Prompt::new("Internal error; press Enter to end the command", Accept::TEXT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(m) = self.broken.get() {
|
||||
return Err(EngineError::Internal(self.inner.name().to_ascii_lowercase(), m.into()));
|
||||
}
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.input(s, i))).unwrap_or_else(|p| Err(self.internal(&*p)))
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p)))
|
||||
}
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
if self.broken.get().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.preview(s, cursor))).unwrap_or_else(|_| {
|
||||
self.broken.set(Some("panic in preview"));
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Running;
|
||||
|
||||
fn dot(p: Vec2) -> EntityKind {
|
||||
EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 })
|
||||
}
|
||||
|
||||
/// Panics in the step named by `at` ("factory", "begin", "prompt", "preview" or "input").
|
||||
struct PanicM(&'static str);
|
||||
|
||||
impl Interactive for PanicM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PANICTEST"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
assert_ne!(self.0, "prompt", "prompt panicked");
|
||||
Prompt::new("Specify a point", Accept::POINT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, _i: Input) -> Result<Step> {
|
||||
s.add_entity(dot(Vec2::ZERO))?;
|
||||
assert_ne!(self.0, "input", "input panicked");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.add_entity(dot(Vec2::ZERO))?;
|
||||
assert_ne!(self.0, "begin", "begin panicked");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
assert_ne!(self.0, "preview", "preview panicked");
|
||||
vec![dot(cursor)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a PanicM the way `Session::start` starts an interactive command.
|
||||
fn start(s: &mut Session, at: &'static str) -> Result<()> {
|
||||
fn factory_panics(_: &Session) -> Result<Box<dyn Interactive>> {
|
||||
panic!("factory panicked")
|
||||
}
|
||||
let factory: Factory = match at {
|
||||
"factory" => factory_panics,
|
||||
"begin" => |_| Ok(Box::new(PanicM("begin"))),
|
||||
"prompt" => |_| Ok(Box::new(PanicM("prompt"))),
|
||||
"preview" => |_| Ok(Box::new(PanicM("preview"))),
|
||||
_ => |_| Ok(Box::new(PanicM("input"))),
|
||||
};
|
||||
let machine = Guarded::create("panictest", factory, s)?;
|
||||
let st = s.state()?;
|
||||
let (before, selection_before) = (st.doc.clone(), st.selection.clone());
|
||||
s.running = Some(Running { id: "panictest".into(), machine, before, selection_before });
|
||||
s.feed(None)
|
||||
}
|
||||
|
||||
fn points(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.iter().count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panics_in_a_command_machine_end_the_command_not_the_app() {
|
||||
// Factory and begin: an internal error, nothing running, the drawing as it was.
|
||||
for at in ["factory", "begin"] {
|
||||
let mut s = Session::new();
|
||||
let e = start(&mut s, at).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::Internal(_, m) if m.contains("panicked")), "{at}: {e}");
|
||||
assert!(s.running.is_none() && s.current_prompt().is_none(), "{at}");
|
||||
assert_eq!(points(&s), 0, "{at}: the drawing is restored");
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
assert!(s.running.is_some(), "{at}: the session still works");
|
||||
}
|
||||
|
||||
// Prompt and preview: a fallback prompt and no preview from then on; the next input ends
|
||||
// the command and restores the drawing.
|
||||
let at = Vec2::new(1.0, 1.0);
|
||||
let mut s = Session::new();
|
||||
start(&mut s, "prompt").unwrap();
|
||||
assert_eq!(points(&s), 1, "begin ran");
|
||||
assert_eq!(s.preview(at).len(), 1, "the preview works until the prompt panics");
|
||||
assert!(s.current_prompt().unwrap().message.contains("Internal error"));
|
||||
assert!(s.prompt_text().starts_with("PANICTEST Internal error"), "{}", s.prompt_text());
|
||||
assert!(s.preview(at).is_empty());
|
||||
assert!(matches!(s.input(Input::Enter), Err(EngineError::Internal(_, m)) if m == "panic in prompt"));
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(points(&s), 0, "the drawing is restored");
|
||||
|
||||
let mut s = Session::new();
|
||||
start(&mut s, "preview").unwrap();
|
||||
assert!(s.preview(at).is_empty());
|
||||
assert!(s.preview(at).is_empty(), "not called again");
|
||||
assert!(s.current_prompt().unwrap().message.contains("Internal error"));
|
||||
assert!(matches!(s.input(Input::Point(at)), Err(EngineError::Internal(_, m)) if m == "panic in preview"));
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(points(&s), 0, "the drawing is restored");
|
||||
|
||||
// Input (already guarded by the session; the wrapper agrees).
|
||||
let mut s = Session::new();
|
||||
start(&mut s, "input").unwrap();
|
||||
assert!(matches!(s.input(Input::Point(Vec2::ZERO)), Err(EngineError::Internal(..))));
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(points(&s), 0);
|
||||
}
|
||||
}
|
||||
+281
-18
@@ -9,8 +9,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod assoc;
|
||||
pub mod audit;
|
||||
pub mod cmd;
|
||||
mod finite;
|
||||
pub mod grips;
|
||||
mod guard;
|
||||
pub mod pointmod;
|
||||
pub mod prompt;
|
||||
pub mod sample;
|
||||
pub mod select;
|
||||
@@ -122,6 +126,35 @@ impl DocState {
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved)
|
||||
}
|
||||
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
|
||||
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
|
||||
/// a name `before` did not have), else go to the Model tab.
|
||||
fn follow_layout(&mut self, before: &Drawing) {
|
||||
let Space::Paper(name) = &self.space else { return };
|
||||
if self.doc.layouts.iter().any(|l| l.name == *name) {
|
||||
return;
|
||||
}
|
||||
let renamed = before
|
||||
.layouts
|
||||
.iter()
|
||||
.find(|l| l.name == *name)
|
||||
.and_then(|old| self.doc.layouts.iter().find(|l| l.tab_order == old.tab_order && !before.layouts.iter().any(|b| b.name == l.name)));
|
||||
match renamed.map(|l| Space::Paper(l.name.clone())) {
|
||||
Some(to) => {
|
||||
let from = self.space.clone();
|
||||
for (sp, _) in self.views.iter_mut() {
|
||||
if *sp == from {
|
||||
*sp = to.clone();
|
||||
}
|
||||
}
|
||||
self.space = to;
|
||||
}
|
||||
None => {
|
||||
self.space = Space::Model;
|
||||
self.mspace = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The space edits, picks and snaps act on: model space inside an active viewport.
|
||||
pub fn edit_space(&self) -> Space {
|
||||
if self.active_viewport().is_some() { Space::Model } else { self.space.clone() }
|
||||
@@ -196,6 +229,36 @@ impl DocState {
|
||||
}
|
||||
self.store_view(v);
|
||||
}
|
||||
/// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and
|
||||
/// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so
|
||||
/// zooming it moves the sheet instead (see `store_view`).
|
||||
pub fn zoom_viewport(&self) -> Option<cadcraft_doc::Viewport> {
|
||||
self.active_viewport().map(|(_, v)| v).filter(|v| !v.locked && v.width > 1e-12)
|
||||
}
|
||||
/// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`].
|
||||
pub fn zoom_frame(&self) -> View {
|
||||
match self.zoom_viewport() {
|
||||
Some(v) => View { center: v.view_center, height: v.view_height },
|
||||
None => self.view(),
|
||||
}
|
||||
}
|
||||
/// Width / height of the area ZOOM fits into: the active unlocked viewport, else `screen`.
|
||||
pub fn zoom_aspect(&self, screen: f64) -> f64 {
|
||||
self.zoom_viewport().map_or(screen, |v| v.width / v.height)
|
||||
}
|
||||
/// Set the view ZOOM works on (see [`Self::zoom_frame`]), recording view history.
|
||||
pub fn set_zoom_frame(&mut self, f: View) {
|
||||
let v = match self.zoom_viewport() {
|
||||
// The screen view that `store_view` maps to this model view of the viewport.
|
||||
Some(vp) => {
|
||||
let pv = self.paper_view();
|
||||
let k = f.height / vp.height;
|
||||
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
}
|
||||
None => f,
|
||||
};
|
||||
self.set_view(v);
|
||||
}
|
||||
/// Set without recording view history (realtime pan/zoom frames).
|
||||
pub fn set_view_quiet(&mut self, v: View) {
|
||||
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
|
||||
@@ -217,10 +280,18 @@ pub struct Settings {
|
||||
pub gridmode: bool,
|
||||
pub snapmode: bool,
|
||||
pub snapunit: Vec2,
|
||||
/// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD).
|
||||
pub osnaphatch: bool,
|
||||
pub gridunit: Vec2,
|
||||
/// Major grid line every N minor lines (GRIDMAJOR).
|
||||
pub gridmajor: u32,
|
||||
pub dynmode: bool,
|
||||
/// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of
|
||||
/// polar `distance<angle` (the default).
|
||||
pub dynpi_cartesian: bool,
|
||||
/// DYNPICOORDS = 1: second and next points typed into Dynamic Input are absolute instead of
|
||||
/// relative to the last point (the default).
|
||||
pub dynpi_absolute: bool,
|
||||
pub lwdisplay: bool,
|
||||
pub transparency_display: bool,
|
||||
pub selection_cycling: bool,
|
||||
@@ -233,11 +304,18 @@ pub struct Settings {
|
||||
pub pickadd: bool,
|
||||
pub gripsize: f64,
|
||||
pub cursorsize: f64,
|
||||
/// MAXARRAY: the most objects one array command creates (items × selected objects).
|
||||
pub maxarray: u64,
|
||||
pub isodraft: bool,
|
||||
pub annoallvisible: bool,
|
||||
pub annoautoscale: bool,
|
||||
/// QPMODE: show the Quick Properties palette when objects are selected.
|
||||
pub qpmode: bool,
|
||||
/// POLARMODE bits ([`snap::tracking::polarmode`]); polar tracking itself is on while
|
||||
/// `polarmode` is (AUTOSNAP bit 8, F10).
|
||||
pub polar_flags: u32,
|
||||
/// POLARADDANG: additional polar tracking angles (radians), used with POLARMODE bit 4.
|
||||
pub polaraddang: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -250,9 +328,12 @@ impl Default for Settings {
|
||||
gridmode: true,
|
||||
snapmode: false,
|
||||
snapunit: Vec2::new(0.5, 0.5),
|
||||
osnaphatch: false,
|
||||
gridunit: Vec2::new(0.5, 0.5),
|
||||
gridmajor: 5,
|
||||
dynmode: true,
|
||||
dynpi_cartesian: false,
|
||||
dynpi_absolute: false,
|
||||
lwdisplay: false,
|
||||
transparency_display: false,
|
||||
selection_cycling: false,
|
||||
@@ -263,10 +344,13 @@ impl Default for Settings {
|
||||
pickadd: true,
|
||||
gripsize: 5.0,
|
||||
cursorsize: 5.0,
|
||||
maxarray: 100_000,
|
||||
isodraft: false,
|
||||
annoallvisible: true,
|
||||
annoautoscale: false,
|
||||
qpmode: false,
|
||||
polar_flags: 0,
|
||||
polaraddang: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,6 +370,93 @@ pub struct PendingWindow {
|
||||
pub during_command: bool,
|
||||
}
|
||||
|
||||
/// Options and values the interactive commands remember for the rest of the session, offered as
|
||||
/// the `<default>` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing
|
||||
/// system variable (HPNAME, OFFSETDIST, FILLETRAD, CHAMFERA, POLYSIDES…) live in the drawing
|
||||
/// header instead. JSON calls never read these: their explicit parameters and documented defaults
|
||||
/// stay authoritative.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LastUsed {
|
||||
/// HATCH/GRADIENT starts at "Select objects" instead of "Pick internal point".
|
||||
pub hatch_select: bool,
|
||||
/// POLYGON: circumscribed about the circle (the `C` option) instead of inscribed.
|
||||
pub polygon_circumscribed: bool,
|
||||
/// ROTATE angle (radians).
|
||||
pub rotate_angle: f64,
|
||||
/// SCALE factor.
|
||||
pub scale_factor: f64,
|
||||
/// MLEADER placement order and Options.
|
||||
pub mleader: MLeaderOptions,
|
||||
/// MLEADER's block content name (Options > Content type > Block).
|
||||
pub mleader_block: String,
|
||||
}
|
||||
|
||||
impl Default for LastUsed {
|
||||
fn default() -> Self {
|
||||
LastUsed {
|
||||
hatch_select: false,
|
||||
polygon_circumscribed: false,
|
||||
rotate_angle: 0.0,
|
||||
scale_factor: 1.0,
|
||||
mleader: MLeaderOptions::default(),
|
||||
mleader_block: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which part of a multileader MLEADER places first.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeaderOrder {
|
||||
#[default]
|
||||
Arrowhead,
|
||||
Landing,
|
||||
Content,
|
||||
}
|
||||
|
||||
/// The MLEADER placement order and the values set at its Options prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MLeaderOptions {
|
||||
pub order: LeaderOrder,
|
||||
/// Maximum points of the leader line (2..=64).
|
||||
pub max_points: usize,
|
||||
/// First and second angle constraints (radians; 0 = free).
|
||||
pub angles: [f64; 2],
|
||||
/// Draw the landing line (dogleg).
|
||||
pub landing: bool,
|
||||
/// Ask for multiline text content (Content type None turns it off).
|
||||
pub content: bool,
|
||||
/// Leader lines: straight, spline, or none (content only).
|
||||
pub leader_type: LeaderType,
|
||||
/// The content is a block (`LastUsed::mleader_block`) instead of multiline text.
|
||||
pub block: bool,
|
||||
/// The block's extents centre (not its insertion point) sits at the end of the landing.
|
||||
pub block_center: bool,
|
||||
}
|
||||
|
||||
/// MLEADER's leader line type (Options > Leader type).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeaderType {
|
||||
#[default]
|
||||
Straight,
|
||||
Spline,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Default for MLeaderOptions {
|
||||
fn default() -> Self {
|
||||
MLeaderOptions {
|
||||
order: LeaderOrder::Arrowhead,
|
||||
max_points: 2,
|
||||
angles: [0.0; 2],
|
||||
landing: true,
|
||||
content: true,
|
||||
leader_type: LeaderType::Straight,
|
||||
block: false,
|
||||
block_center: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor session: open drawings, settings, the running command and the command log.
|
||||
pub struct Session {
|
||||
pub docs: Vec<DocState>,
|
||||
@@ -296,11 +467,16 @@ pub struct Session {
|
||||
pub last_command: Option<String>,
|
||||
/// LASTPOINT.
|
||||
pub last_point: Vec2,
|
||||
/// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `<a`…).
|
||||
pub point_mods: Vec<pointmod::Frame>,
|
||||
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
|
||||
pub cursor: Vec2,
|
||||
/// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the
|
||||
/// rubber band show the line it resolves to).
|
||||
pub cursor_deferred: Option<snap::Deferred>,
|
||||
/// Object snap tracking: acquired points and the path under the cursor (from the UI's
|
||||
/// [`Session::snap_cursor`] calls; direct distance entry follows the path).
|
||||
pub tracking: snap::tracking::Tracker,
|
||||
/// Viewport size in pixels (from the UI; used for zoom and pick apertures).
|
||||
pub viewport_px: (f64, f64),
|
||||
pub clipboard: Vec<Entity>,
|
||||
@@ -309,6 +485,10 @@ pub struct Session {
|
||||
pub untitled_counter: u32,
|
||||
/// The last dimension created (DIMCONTINUE / DIMBASELINE).
|
||||
pub last_dim: Option<Handle>,
|
||||
/// Options remembered between invocations of interactive commands.
|
||||
pub last_used: LastUsed,
|
||||
/// Where `clipboard` came from and the layers, styles and blocks its objects need.
|
||||
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -333,14 +513,18 @@ impl Session {
|
||||
log: Vec::new(),
|
||||
last_command: None,
|
||||
last_point: Vec2::ZERO,
|
||||
point_mods: Vec::new(),
|
||||
cursor: Vec2::ZERO,
|
||||
cursor_deferred: None,
|
||||
tracking: snap::tracking::Tracker::default(),
|
||||
viewport_px: (1200.0, 800.0),
|
||||
clipboard: Vec::new(),
|
||||
clipboard_base: Vec2::ZERO,
|
||||
pending_window: None,
|
||||
untitled_counter: 0,
|
||||
last_dim: None,
|
||||
last_used: LastUsed::default(),
|
||||
clipboard_source: None,
|
||||
}
|
||||
}
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
@@ -417,6 +601,7 @@ impl Session {
|
||||
linetype: h.str("CELTYPE", "ByLayer"),
|
||||
lineweight: cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16),
|
||||
ltscale: h.f64("CELTSCALE", 1.0),
|
||||
plot_style: h.str("CPLOTSTYLE", "ByLayer"),
|
||||
..Common::default()
|
||||
}
|
||||
}
|
||||
@@ -464,8 +649,13 @@ impl Session {
|
||||
return Err(EngineError::Internal(id.into(), msg));
|
||||
}
|
||||
};
|
||||
cmd::groups::after_command(self, before.as_ref().map(|b| &b.0));
|
||||
cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok());
|
||||
assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2));
|
||||
let result = match (result, &before) {
|
||||
(Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v),
|
||||
(r, _) => r,
|
||||
};
|
||||
if spec.undoable
|
||||
&& let Some((doc, sel, uid)) = before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
@@ -498,7 +688,9 @@ impl Session {
|
||||
None => (false, name),
|
||||
};
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
let id = cmd::resolve_alias(&lower);
|
||||
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
|
||||
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
|
||||
let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) };
|
||||
let spec = find_command(&id).ok_or_else(|| EngineError::UnknownCommand(name.to_string()))?;
|
||||
if transparent && spec.transparent && self.running.is_some() {
|
||||
// Transparent commands (zoom, pan…) run without cancelling the active one.
|
||||
@@ -515,7 +707,7 @@ impl Session {
|
||||
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
|
||||
match spec.interactive {
|
||||
Some(factory) => {
|
||||
let machine = factory(self)?;
|
||||
let machine = guard::Guarded::create(spec.id, factory, self)?;
|
||||
let st = self.state()?;
|
||||
let before = st.doc.clone();
|
||||
let selection_before = st.selection.clone();
|
||||
@@ -552,6 +744,9 @@ impl Session {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !self.point_mods.is_empty() {
|
||||
return self.modifier_input(input);
|
||||
}
|
||||
// A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the
|
||||
// point it was picked at.
|
||||
let input = match input {
|
||||
@@ -588,7 +783,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) => {
|
||||
Input::Text(ref t) | Input::Keyword(ref t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let d = self.doc()?;
|
||||
let store = d.space(&space);
|
||||
@@ -600,7 +795,7 @@ impl Session {
|
||||
};
|
||||
match picked {
|
||||
Some(hs) => Ok(Some(Input::Pick(hs))),
|
||||
None => Ok(Some(Input::Text(t))),
|
||||
None => Ok(Some(input)),
|
||||
}
|
||||
}
|
||||
Input::Cancel => {
|
||||
@@ -625,8 +820,18 @@ impl Session {
|
||||
Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())),
|
||||
}
|
||||
}
|
||||
None => run.machine.begin(self),
|
||||
// A command starts with no modifiers pending.
|
||||
None => {
|
||||
self.point_mods.clear();
|
||||
run.machine.begin(self)
|
||||
}
|
||||
};
|
||||
// Redraw what the command has added so far (LINE adds a segment per point), not only when it ends.
|
||||
if let Ok(st) = self.state_mut()
|
||||
&& !Arc::ptr_eq(&run.before, &st.doc)
|
||||
{
|
||||
st.revision += 1;
|
||||
}
|
||||
match step {
|
||||
Ok(Step::Continue) => {
|
||||
self.running = Some(run);
|
||||
@@ -659,9 +864,21 @@ impl Session {
|
||||
}
|
||||
|
||||
fn finish(&mut self, run: Running, cancelled: bool) {
|
||||
if find_command(&run.id).is_some_and(|c| c.undoable)
|
||||
&& let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id)
|
||||
{
|
||||
self.echo(e.to_string());
|
||||
}
|
||||
// A command outside undo that changed the drawing (UNDO, which swapped it for an earlier
|
||||
// one) records no undo step, as in `execute`.
|
||||
if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) {
|
||||
self.pending_window = None;
|
||||
return;
|
||||
}
|
||||
self.pending_window = None;
|
||||
let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command");
|
||||
let _ = cancelled;
|
||||
cmd::groups::after_command(self, Some(&run.before));
|
||||
cmd::constraints::after_command(self, Some(&run.before), true);
|
||||
assoc::after_command(self, Some(&run.before), None);
|
||||
if let Ok(st) = self.state_mut()
|
||||
@@ -675,6 +892,7 @@ impl Session {
|
||||
|
||||
/// Cancel the running command (Esc). With no command, clears the selection.
|
||||
pub fn cancel(&mut self) {
|
||||
self.point_mods.clear();
|
||||
if self.running.is_some() {
|
||||
let _ = self.feed(Some(Input::Cancel));
|
||||
} else {
|
||||
@@ -687,6 +905,16 @@ impl Session {
|
||||
self.running.as_ref().map(|r| r.machine.prompt(self))
|
||||
}
|
||||
|
||||
/// Whether the cursor picks objects right now, so the UI shows the pick box (PICKBOX) at the
|
||||
/// crosshair: at prompts that select objects, and with no command running when PICKFIRST is on
|
||||
/// (noun-verb selection).
|
||||
pub fn picking_objects(&self) -> bool {
|
||||
match self.current_prompt() {
|
||||
Some(p) => p.picks_objects(),
|
||||
None => self.settings.pickfirst,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text shown on the command line: the active prompt, or "Command:".
|
||||
pub fn prompt_text(&self) -> String {
|
||||
match &self.running {
|
||||
@@ -760,23 +988,35 @@ impl Session {
|
||||
if tt.is_empty() {
|
||||
return self.input(Input::Enter);
|
||||
}
|
||||
// FROM, M2P, point filters, `<a` (before `'`: `'_from` is a modifier).
|
||||
if let Some(r) = self.typed_modifier(&prompt, tt) {
|
||||
return r;
|
||||
}
|
||||
// Transparent command.
|
||||
if tt.starts_with('\'') {
|
||||
return self.start(tt);
|
||||
}
|
||||
if let Some(k) = prompt.match_keyword(tt)
|
||||
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
|
||||
let selection_mode = prompt.accept.select && matches!(tt.to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
if !selection_mode
|
||||
&& let Some(k) = prompt.match_keyword(tt)
|
||||
&& !(prompt.accept.number && tt.parse::<f64>().is_ok())
|
||||
{
|
||||
return self.input(Input::Keyword(k));
|
||||
}
|
||||
if prompt.accept.point {
|
||||
if let Some(p) = prompt::parse_point(tt, self.last_point) {
|
||||
if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) {
|
||||
return self.input(Input::Point(p));
|
||||
}
|
||||
// Direct distance entry along the rubber band.
|
||||
if let (Some(base), Some(dist)) = (prompt.base, units::parse_distance(tt))
|
||||
&& !prompt.accept.number
|
||||
{
|
||||
// Along the tracking path the cursor is on, from the point it comes from.
|
||||
if let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along()) {
|
||||
return self.input(Input::Point(from + dir * dist));
|
||||
}
|
||||
let mut c = self.cursor;
|
||||
if self.settings.orthomode {
|
||||
c = snap::ortho(base, c);
|
||||
@@ -803,7 +1043,8 @@ impl Session {
|
||||
}
|
||||
let mut rest = line;
|
||||
loop {
|
||||
let text_prompt = self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
|
||||
// Free text only when the prompt has no keywords; otherwise split so `I 15` reaches the keyword and the value.
|
||||
let text_prompt = self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && p.keywords.is_empty());
|
||||
if text_prompt || self.running.is_none() && rest.contains('{') {
|
||||
self.cmdline(rest)?;
|
||||
break;
|
||||
@@ -838,6 +1079,7 @@ impl Session {
|
||||
let Some(snap) = st.undo.pop() else { return Ok(None) };
|
||||
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
|
||||
st.doc = snap.doc;
|
||||
st.follow_layout(&cur.doc);
|
||||
st.selection = Vec::new();
|
||||
st.redo.push(cur);
|
||||
st.revision += 1;
|
||||
@@ -848,6 +1090,7 @@ impl Session {
|
||||
let Some(snap) = st.redo.pop() else { return Ok(None) };
|
||||
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
|
||||
st.doc = snap.doc;
|
||||
st.follow_layout(&cur.doc);
|
||||
st.selection = Vec::new();
|
||||
st.undo.push(cur);
|
||||
st.revision += 1;
|
||||
@@ -856,6 +1099,19 @@ impl Session {
|
||||
|
||||
// ---------------- picking without a command ----------------
|
||||
|
||||
/// Open a selection window with one corner at `corner`, for press-and-drag selection
|
||||
/// (AutoCAD's PICKDRAG = 2: a drag opens the window wherever it starts, even over an object;
|
||||
/// the next point, where the drag ends, closes it). Only while objects are being selected: no
|
||||
/// command running, or a command asking for objects. Returns whether a window was opened.
|
||||
pub fn begin_window(&mut self, corner: Vec2) -> bool {
|
||||
let during_command = self.running.is_some();
|
||||
if !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {
|
||||
return false;
|
||||
}
|
||||
self.pending_window = Some(PendingWindow { corner, during_command });
|
||||
true
|
||||
}
|
||||
|
||||
/// A click with no command running: pick/toggle objects or start a selection window.
|
||||
///
|
||||
/// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick
|
||||
@@ -901,21 +1157,28 @@ impl Session {
|
||||
|
||||
// ---------------- views ----------------
|
||||
|
||||
/// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX.
|
||||
pub fn zoom_limits(&self) -> Result<Bounds2> {
|
||||
let d = self.doc()?;
|
||||
if let Space::Paper(name) = self.space()
|
||||
&& let Some(sheet) = cadcraft_render::sheet(d, &name)
|
||||
{
|
||||
return Ok(sheet.bounds());
|
||||
}
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
Ok(Bounds2::new(lo, hi))
|
||||
}
|
||||
|
||||
pub fn zoom_extents(&mut self) -> Result<()> {
|
||||
let space = self.space();
|
||||
let ext = self.doc()?.extents(&space);
|
||||
let (w, h) = self.viewport_px;
|
||||
let ext = if ext.is_empty() {
|
||||
let d = self.doc()?;
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
Bounds2::new(lo, hi)
|
||||
} else {
|
||||
ext
|
||||
};
|
||||
let aspect = w / h.max(1.0);
|
||||
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
|
||||
let st = self.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0));
|
||||
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
|
||||
self.state_mut()?.set_view(View { center: ext.center(), height });
|
||||
st.set_zoom_frame(View { center: ext.center(), height });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Point modifiers typed at a point prompt, as in AutoCAD: `FROM` (a base point and an offset),
|
||||
//! `M2P`/`MTP` (the midpoint between two points), the point filters `.x`, `.y`, `.z`, `.xy`,
|
||||
//! `.xz`, `.yz`, the angle override `<a` and the object snap overrides (`END`, `MID` … `NON`).
|
||||
//!
|
||||
//! A modifier pushes a [`Frame`] onto [`Session::point_mods`]. While frames are pending the
|
||||
//! session shows their prompt ("Base point:", "<Offset>:", "First point of mid:", ".X of",
|
||||
//! "(need YZ):") instead of the command's, collects the points they need and finally hands one
|
||||
//! point to the command. Every point prompt of every command gets them this way, from the command
|
||||
//! line and in scripts alike, and they nest (`FROM` then `M2P`).
|
||||
|
||||
use cadcraft_doc::{Drawing, Space};
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use crate::prompt::{Accept, Input, Prompt};
|
||||
use crate::snap::{SnapHit, mode};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The most modifiers pending at once (bounds hostile scripts such as `FROM FROM FROM …`).
|
||||
const MAX_FRAMES: usize = 32;
|
||||
|
||||
/// The coordinates a point filter takes from its first point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Axes {
|
||||
pub x: bool,
|
||||
pub y: bool,
|
||||
pub z: bool,
|
||||
}
|
||||
|
||||
impl Axes {
|
||||
/// `.x`, `.y`, `.z`, `.xy`, `.xz` or `.yz` (case-insensitive).
|
||||
pub fn parse(t: &str) -> Option<Axes> {
|
||||
let a = match t.to_ascii_lowercase().as_str() {
|
||||
".x" => (true, false, false),
|
||||
".y" => (false, true, false),
|
||||
".z" => (false, false, true),
|
||||
".xy" => (true, true, false),
|
||||
".xz" => (true, false, true),
|
||||
".yz" => (false, true, true),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Axes { x: a.0, y: a.1, z: a.2 })
|
||||
}
|
||||
|
||||
fn letters(self, keep: bool) -> String {
|
||||
[(self.x, 'X'), (self.y, 'Y'), (self.z, 'Z')].iter().filter(|(on, _)| *on == keep).map(|(_, c)| *c).collect()
|
||||
}
|
||||
|
||||
/// The filter as typed: ".XY".
|
||||
pub fn name(self) -> String {
|
||||
format!(".{}", self.letters(true))
|
||||
}
|
||||
|
||||
/// The coordinates still needed: "YZ".
|
||||
pub fn need(self) -> String {
|
||||
self.letters(false)
|
||||
}
|
||||
|
||||
/// The filtered coordinates of `first` with the rest from `second` (Z is not stored: there
|
||||
/// is no 3D yet, so a Z taken from either point is dropped).
|
||||
pub fn combine(self, first: Vec2, second: Vec2) -> Vec2 {
|
||||
Vec2::new(if self.x { first.x } else { second.x }, if self.y { first.y } else { second.y })
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending point modifier.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Frame {
|
||||
/// `FROM`: the base point once given, then the offset point.
|
||||
From { base: Option<Vec2> },
|
||||
/// `M2P`/`MTP`: the first point once given, then the second.
|
||||
Mid { first: Option<Vec2> },
|
||||
/// A point filter: the point to take `keep` from once given, then the rest.
|
||||
Filter { keep: Axes, first: Option<Vec2> },
|
||||
/// `<a`: the next point lies on the line through `base` in direction `dir` (a unit vector).
|
||||
Angle { base: Vec2, dir: Vec2 },
|
||||
/// An object snap override for the next point only: one [`mode`] bit, or 0 for `NON` (no
|
||||
/// object snap).
|
||||
Snap { mode: u32 },
|
||||
}
|
||||
|
||||
/// The object snap overrides and the words they are typed as: any start of a word of three
|
||||
/// letters or more (`END`, `ENDP`, `endpoint`).
|
||||
const SNAP_WORDS: [(u32, &[&str]); 15] = [
|
||||
(mode::END, &["endpoint"]),
|
||||
(mode::MID, &["midpoint"]),
|
||||
(mode::CEN, &["center", "centre"]),
|
||||
(mode::GCEN, &["gcenter", "gcentre"]),
|
||||
(mode::NOD, &["node"]),
|
||||
(mode::QUA, &["quadrant"]),
|
||||
(mode::INT, &["intersection"]),
|
||||
(mode::APP, &["apparent", "appint"]),
|
||||
(mode::EXT, &["extension"]),
|
||||
(mode::INS, &["insertion"]),
|
||||
(mode::PER, &["perpendicular"]),
|
||||
(mode::TAN, &["tangent"]),
|
||||
(mode::NEA, &["nearest"]),
|
||||
(mode::PAR, &["parallel"]),
|
||||
(0, &["none"]),
|
||||
];
|
||||
|
||||
/// The object snap override `t` names (`_endp`, `MID`, `non` …): its mode bit, 0 for `NON`.
|
||||
pub fn parse_snap(t: &str) -> Option<u32> {
|
||||
let b = bare(t).to_ascii_lowercase();
|
||||
if b.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
SNAP_WORDS.iter().find(|(_, words)| words.iter().any(|w| w.starts_with(&b))).map(|(m, _)| *m)
|
||||
}
|
||||
|
||||
/// The name of a snap mode ("Endpoint"), "None" for 0.
|
||||
pub fn snap_name(m: u32) -> &'static str {
|
||||
mode::ALL.iter().find(|(b, _)| *b == m).map_or("None", |(_, n)| *n)
|
||||
}
|
||||
|
||||
/// The candidate snaps near `cursor` for Tab cycling: the snap [`crate::snap::osnap`] picks
|
||||
/// first, then the closest snap of each other mode in `osmode`, nearest first, without repeats.
|
||||
pub fn snap_candidates(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u32, base: Option<Vec2>, deferred: bool) -> Vec<SnapHit> {
|
||||
if osmode & !mode::HATCH == 0 || osmode & mode::OFF != 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let osnap = |m: u32| crate::snap::osnap(d, space, cursor, aperture, m, base, deferred);
|
||||
let extra = osmode & mode::HATCH;
|
||||
let mut per_mode: Vec<SnapHit> = mode::ALL.iter().filter(|(b, _)| osmode & b != 0).filter_map(|(b, _)| osnap(*b | extra)).collect();
|
||||
per_mode.sort_by(|a, b| a.point.dist(cursor).total_cmp(&b.point.dist(cursor)));
|
||||
let mut out: Vec<SnapHit> = osnap(osmode).into_iter().collect();
|
||||
for h in per_mode {
|
||||
if !out.iter().any(|o| o.mode == h.mode && o.point.near(h.point, aperture * 1e-6)) {
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip the international (`_`) and transparent (`'`) prefixes a modifier may be typed with.
|
||||
fn bare(t: &str) -> &str {
|
||||
t.trim().trim_start_matches(['\'', '_'])
|
||||
}
|
||||
|
||||
/// Whether point modifiers apply at this prompt: it asks for a point that is not an object pick.
|
||||
pub fn accepts(prompt: &Prompt) -> bool {
|
||||
prompt.accept.point && !prompt.picks_objects()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The prompt shown while modifiers are pending, built over the command's own prompt.
|
||||
pub(crate) fn overlay_prompt(&self, cmd: Prompt) -> Prompt {
|
||||
let point = Accept::POINT;
|
||||
self.point_mods.iter().fold(cmd, |p, f| match *f {
|
||||
Frame::From { base: None } => Prompt::new("Base point", point),
|
||||
Frame::From { base: Some(b) } => Prompt::new("<Offset>", point).base(b),
|
||||
Frame::Mid { first: None } => Prompt::new("First point of mid", point),
|
||||
Frame::Mid { first: Some(f) } => Prompt::new("Second point of mid", point).base(f),
|
||||
Frame::Filter { keep, first: None } => Prompt::new(format!("{} of", keep.name()), point).base_opt(p.base),
|
||||
Frame::Filter { keep, first: Some(_) } => Prompt::new(format!("(need {})", keep.need()), point).base_opt(p.base),
|
||||
Frame::Angle { .. } | Frame::Snap { mode: 0 } => p,
|
||||
// "Endpoint of:", keeping the base and deferral PER/TAN need.
|
||||
Frame::Snap { mode } => Prompt { message: format!("{} of", snap_name(mode)), keywords: Vec::new(), default: None, ..p },
|
||||
})
|
||||
}
|
||||
|
||||
/// The object snap override for the next pick ([`Frame::Snap`]): `Some(0)` after `NON`,
|
||||
/// `None` when the running object snaps apply. The UI snaps the cursor with it.
|
||||
pub fn snap_override(&self) -> Option<u32> {
|
||||
match self.point_mods.last() {
|
||||
Some(Frame::Snap { mode }) if self.running.is_some() => Some(*mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifier `t` names at `prompt`, if any.
|
||||
fn parse_modifier(&self, prompt: &Prompt, t: &str) -> Option<Frame> {
|
||||
// The command's keywords come first (`cen` is ZOOM's or ARC's Center, `ext` ZOOM's
|
||||
// Extents); the `_` and `'` forms (`_cen`) always name the modifier.
|
||||
if prompt.match_keyword(t).is_some() {
|
||||
return None;
|
||||
}
|
||||
let b = bare(t);
|
||||
if let Some(a) = b.strip_prefix('<') {
|
||||
// `<a`, `<<a`, `<<<a`, read like the angle of `@d<a`.
|
||||
let angles = self.angle_settings();
|
||||
let ang = if a.starts_with('<') { angles.direction(&format!("<{a}"))? } else { angles.direction(a)? };
|
||||
let dir = Vec2::from_angle(ang);
|
||||
let base = prompt.base.unwrap_or(self.last_point);
|
||||
return (dir.is_finite() && base.is_finite()).then_some(Frame::Angle { base, dir });
|
||||
}
|
||||
if let Some(keep) = Axes::parse(b) {
|
||||
return Some(Frame::Filter { keep, first: None });
|
||||
}
|
||||
if let Some(mode) = parse_snap(b) {
|
||||
return Some(Frame::Snap { mode });
|
||||
}
|
||||
match b.to_ascii_lowercase().as_str() {
|
||||
"from" | "fro" => Some(Frame::From { base: None }),
|
||||
"m2p" | "mtp" => Some(Frame::Mid { first: None }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A token typed at a point prompt that a modifier handles: a modifier name, or a distance
|
||||
/// along an angle override. `None` leaves the token to the usual parsing.
|
||||
pub(crate) fn typed_modifier(&mut self, prompt: &Prompt, t: &str) -> Option<Result<()>> {
|
||||
if !accepts(prompt) {
|
||||
return None;
|
||||
}
|
||||
if let Some(f) = self.parse_modifier(prompt, t) {
|
||||
if self.point_mods.len() >= MAX_FRAMES {
|
||||
self.echo("Too many point modifiers.");
|
||||
return Some(Ok(()));
|
||||
}
|
||||
match f {
|
||||
Frame::Angle { .. } => self.echo(format!("Angle Override: {}", bare(t).trim_start_matches('<'))),
|
||||
// A second snap override replaces the first.
|
||||
Frame::Snap { .. } if matches!(self.point_mods.last(), Some(Frame::Snap { .. })) => {
|
||||
self.point_mods.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.point_mods.push(f);
|
||||
return Some(Ok(()));
|
||||
}
|
||||
if let Some(Frame::Angle { base, dir }) = self.point_mods.last().copied() {
|
||||
// Typed coordinates are explicit: they win over the override.
|
||||
if crate::prompt::parse_point_with(t, self.last_point, &self.angle_settings()).is_some() {
|
||||
self.point_mods.pop();
|
||||
return None;
|
||||
}
|
||||
if let Some(d) = crate::units::parse_distance(t) {
|
||||
self.point_mods.pop();
|
||||
return Some(self.modifier_point(base + dir * d));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An input while modifiers are pending.
|
||||
pub(crate) fn modifier_input(&mut self, input: Input) -> Result<()> {
|
||||
// An angle override only constrains a point: anything else goes to the prompt below it.
|
||||
if let Some(Frame::Angle { .. }) = self.point_mods.last()
|
||||
&& !matches!(input, Input::Point(_) | Input::Deferred(_))
|
||||
{
|
||||
self.point_mods.pop();
|
||||
return self.input(input);
|
||||
}
|
||||
match input {
|
||||
Input::Point(p) => self.modifier_point(p),
|
||||
// A deferred tangent/perpendicular picked under a TAN/PER override.
|
||||
Input::Deferred(d) if matches!(self.point_mods.as_slice(), [Frame::Snap { .. }]) => {
|
||||
self.point_mods.clear();
|
||||
self.input(Input::Deferred(d))
|
||||
}
|
||||
Input::Deferred(d) => self.modifier_point(d.at),
|
||||
// Enter drops the modifiers and returns to the command's prompt.
|
||||
Input::Enter => {
|
||||
self.point_mods.clear();
|
||||
Ok(())
|
||||
}
|
||||
Input::Cancel => {
|
||||
self.point_mods.clear();
|
||||
self.input(Input::Cancel)
|
||||
}
|
||||
Input::Keyword(_) | Input::Text(_) | Input::Pick(_) => {
|
||||
self.echo("Invalid point.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point given while modifiers are pending: complete the top frame, and when it has its
|
||||
/// point, hand that to the frame (or command) below.
|
||||
pub(crate) fn modifier_point(&mut self, mut p: Vec2) -> Result<()> {
|
||||
if !p.is_finite() {
|
||||
self.echo("Invalid point.");
|
||||
return Ok(());
|
||||
}
|
||||
while let Some(top) = self.point_mods.pop() {
|
||||
match top {
|
||||
Frame::From { base: None } => {
|
||||
self.point_mods.push(Frame::From { base: Some(p) });
|
||||
// `@dx,dy` at "<Offset>:" is measured from the base point.
|
||||
self.last_point = p;
|
||||
return Ok(());
|
||||
}
|
||||
Frame::Mid { first: None } => {
|
||||
self.point_mods.push(Frame::Mid { first: Some(p) });
|
||||
self.last_point = p;
|
||||
return Ok(());
|
||||
}
|
||||
Frame::Filter { keep, first: None } => {
|
||||
self.point_mods.push(Frame::Filter { keep, first: Some(p) });
|
||||
return Ok(());
|
||||
}
|
||||
// The offset point is already measured from the base (`@` reads the last point).
|
||||
Frame::From { base: Some(_) } => {}
|
||||
Frame::Mid { first: Some(f) } => p = (f + p) * 0.5,
|
||||
Frame::Filter { keep, first: Some(f) } => p = keep.combine(f, p),
|
||||
Frame::Angle { base, dir } => p = base + dir * (p - base).dot(dir),
|
||||
Frame::Snap { mode: 0 } => {}
|
||||
Frame::Snap { mode } => {
|
||||
// Snap the point with this mode alone, within the aperture.
|
||||
let prompt = self.current_prompt();
|
||||
let space = self.space();
|
||||
let aperture = self.pixel_size() * self.settings.aperture.max(1.0);
|
||||
let hatch = if self.settings.osnaphatch { mode::HATCH } else { 0 };
|
||||
let base = prompt.as_ref().and_then(|q| q.base);
|
||||
let deferred = prompt.as_ref().is_some_and(|q| q.deferred);
|
||||
let hit = crate::snap::osnap(self.doc()?, &space, p, aperture, mode | hatch, base, deferred);
|
||||
match hit {
|
||||
Some(SnapHit { deferred: Some(d), .. }) if self.point_mods.is_empty() => return self.input(Input::Deferred(d)),
|
||||
Some(h) => p = h.point,
|
||||
None => {
|
||||
self.echo(format!("No {} found for specified point.", snap_name(mode)));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.point_mods.clear();
|
||||
self.feed(Some(Input::Point(p)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters() {
|
||||
let a = Axes::parse(".X").unwrap();
|
||||
assert_eq!((a.name().as_str(), a.need().as_str()), (".X", "YZ"));
|
||||
assert_eq!(a.combine(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Vec2::new(1.0, 4.0));
|
||||
let a = Axes::parse(".yz").unwrap();
|
||||
assert_eq!((a.name().as_str(), a.need().as_str()), (".YZ", "X"));
|
||||
assert_eq!(a.combine(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Vec2::new(3.0, 2.0));
|
||||
assert!(Axes::parse(".5").is_none() && Axes::parse(".w").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_words() {
|
||||
for (t, m) in [("END", mode::END), ("_endp", mode::END), ("'_mid", mode::MID), ("cen", mode::CEN), ("gcen", mode::GCEN)] {
|
||||
assert_eq!(parse_snap(t), Some(m), "{t}");
|
||||
}
|
||||
for (t, m) in [("perp", mode::PER), ("Insert", mode::INS), ("appint", mode::APP), ("NON", 0), ("none", 0)] {
|
||||
assert_eq!(parse_snap(t), Some(m), "{t}");
|
||||
}
|
||||
for t in ["en", "endx", "line", "x", "no"] {
|
||||
assert_eq!(parse_snap(t), None, "{t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-12
@@ -68,11 +68,25 @@ impl Prompt {
|
||||
self.default = Some(d.into());
|
||||
self
|
||||
}
|
||||
/// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects
|
||||
/// one object ("Select object to offset", "Select first line"…, the commands that pick a
|
||||
/// single object themselves). The UI shows the pick box there.
|
||||
pub fn picks_objects(&self) -> bool {
|
||||
self.accept.select || (self.accept.point && self.message.starts_with("Select"))
|
||||
}
|
||||
/// "Specify next point or [Undo/Close] <default>:".
|
||||
pub fn display(&self) -> String {
|
||||
let mut s = self.message.clone();
|
||||
if !self.keywords.is_empty() {
|
||||
s.push_str(if self.message.is_empty() { "[" } else { " or [" });
|
||||
// A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else
|
||||
// offers options besides what it asks for ("Specify next point or [Undo]").
|
||||
s.push_str(if self.message.is_empty() {
|
||||
"["
|
||||
} else if self.message.ends_with('?') {
|
||||
" ["
|
||||
} else {
|
||||
" or ["
|
||||
});
|
||||
s.push_str(&self.keywords.join("/"));
|
||||
s.push(']');
|
||||
}
|
||||
@@ -89,16 +103,18 @@ impl Prompt {
|
||||
return None;
|
||||
}
|
||||
let tl = t.to_ascii_lowercase();
|
||||
for k in &self.keywords {
|
||||
let keys = self.keywords.iter().map(|k| {
|
||||
let shortcut: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect::<String>().to_ascii_lowercase();
|
||||
let shortcut =
|
||||
if shortcut.is_empty() { k.chars().next().map(|c| c.to_ascii_lowercase().to_string()).unwrap_or_default() } else { shortcut };
|
||||
let kl = k.to_ascii_lowercase();
|
||||
if tl == kl || tl == shortcut || (tl.len() >= shortcut.len() && kl.starts_with(&tl)) {
|
||||
return Some(k.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
(k, k.to_ascii_lowercase(), shortcut)
|
||||
});
|
||||
// A whole keyword or its capital-letter shortcut wins over a prefix of another keyword
|
||||
// (`M` is Multiple, not a prefix of mEthod).
|
||||
keys.clone()
|
||||
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
|
||||
.or_else(|| keys.clone().find(|(_, kl, sc)| tl.len() >= sc.len() && kl.starts_with(&tl)))
|
||||
.map(|(k, _, _)| k.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,15 +158,26 @@ pub trait Interactive: Send {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist<angle`, `dist<angle`, `#x,y` (absolute).
|
||||
/// Relative forms use `last`.
|
||||
/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist<angle`, `dist<angle`, `#x,y` (absolute),
|
||||
/// `*x,y` and `*@dx,dy`/`@*dx,dy` (world coordinates: the same as the plain forms, as there is no
|
||||
/// UCS yet).
|
||||
/// Relative forms use `last`. Polar angles read the default angle settings (degrees from +X,
|
||||
/// counterclockwise); `parse_point_with` reads a drawing's.
|
||||
pub fn parse_point(text: &str, last: Vec2) -> Option<Vec2> {
|
||||
parse_point_with(text, last, &crate::units::AngleSettings::default())
|
||||
}
|
||||
|
||||
/// `parse_point` with polar angles read per `angles` (AUNITS, ANGBASE, ANGDIR); `d<<a` and
|
||||
/// `d<<<a` measure from +X counterclockwise (in degrees and in AUNITS).
|
||||
pub fn parse_point_with(text: &str, last: Vec2, angles: &crate::units::AngleSettings) -> Option<Vec2> {
|
||||
let t = text.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let t = t.strip_prefix('*').map_or(t, str::trim_start);
|
||||
let (rel, body) = if let Some(b) = t.strip_prefix('@') {
|
||||
(true, b.trim())
|
||||
let b = b.trim();
|
||||
(true, b.strip_prefix('*').map_or(b, str::trim_start))
|
||||
} else if let Some(b) = t.strip_prefix('#') {
|
||||
(false, b.trim())
|
||||
} else {
|
||||
@@ -161,7 +188,8 @@ pub fn parse_point(text: &str, last: Vec2) -> Option<Vec2> {
|
||||
}
|
||||
let p = if let Some((d, a)) = body.split_once('<') {
|
||||
let dist = crate::units::parse_distance(d)?;
|
||||
let ang = crate::units::parse_angle(a)?;
|
||||
// `d<<a` / `d<<<a`: the override prefixes keep their `<<` / `<<<`.
|
||||
let ang = if a.starts_with('<') { angles.direction(&format!("<{a}"))? } else { angles.direction(a)? };
|
||||
Vec2::from_angle(ang) * dist
|
||||
} else {
|
||||
let parts: Vec<&str> = body.split(',').collect();
|
||||
@@ -193,6 +221,10 @@ mod tests {
|
||||
assert!(parse_point("5<0", last).unwrap().near(Vec2::new(5.0, 0.0), 1e-9));
|
||||
assert_eq!(parse_point("#1,1", last), Some(Vec2::new(1.0, 1.0)));
|
||||
assert_eq!(parse_point("@", last), Some(last));
|
||||
assert_eq!(parse_point("*3,4", last), Some(Vec2::new(3.0, 4.0)));
|
||||
assert_eq!(parse_point("*@1,2", last), Some(Vec2::new(11.0, 12.0)));
|
||||
assert_eq!(parse_point("@*1,2", last), Some(Vec2::new(11.0, 12.0)));
|
||||
assert_eq!(parse_point("*", last), None);
|
||||
assert_eq!(parse_point("1", last), None);
|
||||
assert_eq!(parse_point("a,b", last), None);
|
||||
assert_eq!(parse_point("1,2,3,4", last), None);
|
||||
@@ -202,6 +234,8 @@ mod tests {
|
||||
fn keywords() {
|
||||
let p = Prompt::new("Specify next point", Accept::POINT).kw(&["Undo", "Close"]);
|
||||
assert_eq!(p.display(), "Specify next point or [Undo/Close]:");
|
||||
let q = Prompt::new("Erase polyline?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
|
||||
assert_eq!(q.display(), "Erase polyline? [Yes/No] <No>:");
|
||||
assert_eq!(p.match_keyword("u").as_deref(), Some("Undo"));
|
||||
assert_eq!(p.match_keyword("CL").as_deref(), Some("Close"));
|
||||
assert_eq!(p.match_keyword("x"), None);
|
||||
|
||||
@@ -146,6 +146,7 @@ pub fn bracket() -> Drawing {
|
||||
gradient: None,
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
})
|
||||
};
|
||||
add(on("Hatch"), hatch(Vec2::new(sx, c.y + 0.875), Vec2::new(sx + 0.5, y0 + h)));
|
||||
@@ -207,7 +208,7 @@ pub fn bracket() -> Drawing {
|
||||
add(on("Notes"), text(Vec2::new(4.1, -0.3), 0.16, "FRONT VIEW"));
|
||||
add(
|
||||
on("Notes"),
|
||||
EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0 }),
|
||||
EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0, line_spacing_exact: false }),
|
||||
);
|
||||
let tb0 = Vec2::new(14.0, -0.5);
|
||||
add(on("Title"), lwpoly(rect_vertices(tb0, tb0 + Vec2::new(4.0, 2.0)), true));
|
||||
|
||||
+80
-28
@@ -165,35 +165,49 @@ pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) ->
|
||||
|
||||
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
|
||||
let tol = (bx.width() + bx.height()).max(1e-9) / 2000.0;
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
}
|
||||
let infinite = is_infinite(e);
|
||||
let eb = bounds_of(d, e, known);
|
||||
if !infinite && bx.contains_box(&eb) {
|
||||
out.push(e.handle);
|
||||
continue;
|
||||
}
|
||||
if !crossing || (!infinite && !eb.intersects(&bx)) {
|
||||
continue;
|
||||
}
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
let hit = polys.iter().any(|pl| {
|
||||
if pl.len() == 1 {
|
||||
pl.first().is_some_and(|p| bx.contains(*p))
|
||||
} else {
|
||||
pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| seg_hits_box(*a, *b, &bx)))
|
||||
}
|
||||
});
|
||||
// A crossing box entirely inside a filled/text object also selects it.
|
||||
if hit || (eb.contains_box(&bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) {
|
||||
out.push(e.handle);
|
||||
}
|
||||
let tol = window_tol(&bx);
|
||||
cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The tessellation tolerance window and crossing tests use for a box.
|
||||
pub(crate) fn window_tol(bx: &Bounds2) -> f64 {
|
||||
(bx.width() + bx.height()).max(1e-9) / 2000.0
|
||||
}
|
||||
|
||||
/// Whether a window box (`crossing` false: entirely inside) or a crossing box (inside or
|
||||
/// touching its geometry, not just its bounding box) selects `e`.
|
||||
pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option<Bounds2>, bx: &Bounds2, crossing: bool, tol: f64) -> bool {
|
||||
let infinite = is_infinite(e);
|
||||
let eb = bounds_of(d, e, known);
|
||||
if !infinite && bx.contains_box(&eb) {
|
||||
return true;
|
||||
}
|
||||
out
|
||||
if !crossing || (!infinite && !eb.intersects(bx)) {
|
||||
return false;
|
||||
}
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
let hit = polys.iter().any(|pl| {
|
||||
if pl.len() == 1 {
|
||||
pl.first().is_some_and(|p| bx.contains(*p))
|
||||
} else {
|
||||
pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| seg_hits_box(*a, *b, bx)))
|
||||
}
|
||||
});
|
||||
// A crossing box entirely inside a text/block bounding rectangle also selects it.
|
||||
if hit || (eb.contains_box(bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) {
|
||||
return true;
|
||||
}
|
||||
// SOLID/TRACE are filled polygons. A crossing window wholly within the
|
||||
// fill must select them even when it does not touch their outline. Check
|
||||
// the polygon, not just its bounding rectangle (which can cover empty
|
||||
// space around triangular solids).
|
||||
if matches!(e.kind, EntityKind::Solid(_) | EntityKind::Trace(_)) && eb.contains_box(bx) {
|
||||
return e.kind.prims().iter().any(|prim| match prim {
|
||||
Prim::Fill(poly) => bx.corners().iter().all(|p| cadcraft_geom::point_in_polygon(poly, *p)),
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Fence selection: entities crossed by the fence polyline.
|
||||
@@ -254,3 +268,41 @@ mod tests {
|
||||
assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod solid_interior_tests {
|
||||
use super::*;
|
||||
|
||||
fn face(kind: bool, triangular: bool) -> Entity {
|
||||
let a = Vec2::new(0.0, 0.0).to3(0.0);
|
||||
let b = Vec2::new(10.0, 0.0).to3(0.0);
|
||||
let c = Vec2::new(0.0, 10.0).to3(0.0);
|
||||
let d = if triangular { c } else { Vec2::new(10.0, 10.0).to3(0.0) };
|
||||
let solid = cadcraft_doc::Solid { corners: [a, b, c, d] };
|
||||
Entity::new(Handle(256), if kind { EntityKind::Solid(solid) } else { EntityKind::Trace(solid) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_window_inside_solid_and_trace_selects_fill() {
|
||||
let inside = Bounds2::new(Vec2::new(4.0, 4.0), Vec2::new(6.0, 6.0));
|
||||
for solid in [true, false] {
|
||||
let mut d = Drawing::default();
|
||||
let e = face(solid, false);
|
||||
assert!(in_window(&d, &e, None, &inside, true, 0.01));
|
||||
assert!(!in_window(&d, &e, None, &inside, false, 0.01));
|
||||
d.model.push(e);
|
||||
assert_eq!(select_window(&d, &Space::Model, inside, true), vec![Handle(256)]);
|
||||
assert!(select_window(&d, &Space::Model, inside, false).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_window_in_triangular_bbox_but_outside_fill_does_not_select() {
|
||||
let d = Drawing::default();
|
||||
let e = face(true, true);
|
||||
let empty = Bounds2::new(Vec2::new(7.0, 7.0), Vec2::new(8.0, 8.0));
|
||||
assert!(!in_window(&d, &e, None, &empty, true, 0.01));
|
||||
let inside = Bounds2::new(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0));
|
||||
assert!(in_window(&d, &e, None, &inside, true, 0.01));
|
||||
}
|
||||
}
|
||||
+384
-84
@@ -1,7 +1,10 @@
|
||||
//! Object snaps (OSNAP), polar tracking and ortho.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Prim, Space};
|
||||
use cadcraft_geom::{Bounds2, Circle, PI, Segment, Vec2, intersect_ext};
|
||||
mod curves;
|
||||
pub mod tracking;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space};
|
||||
use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
|
||||
use serde::Serialize;
|
||||
|
||||
/// OSMODE bits.
|
||||
@@ -22,6 +25,8 @@ pub mod mode {
|
||||
pub const PAR: u32 = 8192;
|
||||
/// Running snaps temporarily off (F3).
|
||||
pub const OFF: u32 = 16384;
|
||||
/// Not an OSMODE bit: snaps also find hatch objects (OSNAPHATCH = 1).
|
||||
pub const HATCH: u32 = 1 << 16;
|
||||
pub const ALL: [(u32, &str); 14] = [
|
||||
(END, "Endpoint"),
|
||||
(MID, "Midpoint"),
|
||||
@@ -145,10 +150,59 @@ pub(crate) fn osnap_with(
|
||||
if osmode == 0 || osmode & mode::OFF != 0 {
|
||||
return None;
|
||||
}
|
||||
let (mut cands, near_prims) = gather(d, space, ix, cursor, aperture, osmode)?;
|
||||
// Intersections between nearby primitives; Apparent Intersection also where their extensions
|
||||
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
|
||||
// that don't reach each other).
|
||||
if osmode & (mode::INT | mode::APP) != 0 {
|
||||
let segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
|
||||
let real = if osmode & mode::INT != 0 { mode::INT } else { mode::APP };
|
||||
for i in 0..segs.len() {
|
||||
for j in i + 1..segs.len() {
|
||||
// Extending a tessellated ellipse or spline chord means nothing.
|
||||
let extend = osmode & mode::APP != 0 && [i, j].iter().all(|k| near_prims.get(*k).is_some_and(extendable));
|
||||
for a in segs.get(i).into_iter().flatten() {
|
||||
for b in segs.get(j).into_iter().flatten() {
|
||||
for x in intersect_ext(&fix_circle(a), &fix_circle(b), false) {
|
||||
if x.dist(cursor) <= aperture {
|
||||
cands.push((real, x));
|
||||
}
|
||||
}
|
||||
if extend {
|
||||
for x in intersect_ext(a, b, true) {
|
||||
if x.is_finite() && x.dist(cursor) <= aperture {
|
||||
cands.push((mode::APP, x));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(cands, &near_prims, cursor, aperture, osmode, base, deferred)
|
||||
}
|
||||
|
||||
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
|
||||
pub(crate) fn extendable(p: &Prim) -> bool {
|
||||
matches!(p, Prim::Seg(_) | Prim::Circle(_) | Prim::Infinite { .. })
|
||||
}
|
||||
|
||||
/// The snap candidates of `osmode` and the primitives within `aperture` of `cursor`; `None`
|
||||
/// when the drawing can't be searched.
|
||||
pub(crate) fn gather(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
||||
cursor: Vec2,
|
||||
aperture: f64,
|
||||
osmode: u32,
|
||||
) -> Option<(Vec<(u32, Vec2)>, Vec<Prim>)> {
|
||||
let probe = Bounds2::new(cursor, cursor).expand(aperture);
|
||||
let mut cands: Vec<(u32, Vec2)> = Vec::new();
|
||||
let mut near_prims: Vec<Prim> = Vec::new();
|
||||
let mut count = 0usize;
|
||||
let mut budget = MAX_BLOCK_LEAVES;
|
||||
for (e, known) in crate::select::candidates(d, space, ix, &probe.expand(aperture), true, false)? {
|
||||
if !d.is_visible(e) {
|
||||
continue;
|
||||
@@ -163,18 +217,142 @@ pub(crate) fn osnap_with(
|
||||
}
|
||||
if osmode & mode::INS != 0 {
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) => cands.push((mode::INS, t.insert.xy())),
|
||||
EntityKind::Text(t) => cands.push((mode::INS, t.justify_point())),
|
||||
EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())),
|
||||
EntityKind::Insert(i) => cands.push((mode::INS, i.insert.xy())),
|
||||
EntityKind::Insert(i) => {
|
||||
cands.push((mode::INS, i.insert.xy()));
|
||||
cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.justify_point())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if osmode & mode::END != 0
|
||||
&& let EntityKind::Solid(s) | EntityKind::Trace(s) = &e.kind
|
||||
{
|
||||
cands.extend(s.corners.iter().map(|c| (mode::END, c.xy())));
|
||||
let mut sink = Sink { osmode, aperture, probe, cands: &mut cands, near: &mut near_prims };
|
||||
sink.object(&e.kind, None);
|
||||
// A block reference snaps to the objects inside it, placed by its transform.
|
||||
if let EntityKind::Insert(ins) = &e.kind {
|
||||
block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, &mut |k, m| sink.object(k, Some(m)));
|
||||
}
|
||||
for p in e.kind.prims() {
|
||||
if let EntityKind::Dimension(dm) = &e.kind {
|
||||
sink.dimension(d, dm, &mut budget);
|
||||
}
|
||||
}
|
||||
Some((cands, near_prims))
|
||||
}
|
||||
|
||||
/// Picks the best of the gathered candidates, adding the base-dependent and deferred snaps.
|
||||
fn finish(
|
||||
mut cands: Vec<(u32, Vec2)>,
|
||||
near_prims: &[Prim],
|
||||
cursor: Vec2,
|
||||
aperture: f64,
|
||||
osmode: u32,
|
||||
base: Option<Vec2>,
|
||||
deferred: bool,
|
||||
) -> Option<SnapHit> {
|
||||
// Perpendicular / tangent from the base point.
|
||||
if let Some(bp) = base {
|
||||
for p in near_prims {
|
||||
if osmode & mode::PER != 0 {
|
||||
match p {
|
||||
Prim::Seg(Segment::Line(l)) => cands.push((mode::PER, l.project(bp))),
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => cands.push((mode::PER, Circle::new(arc.center, arc.radius).closest(bp))),
|
||||
Prim::Circle(c) => cands.push((mode::PER, c.closest(bp))),
|
||||
Prim::Infinite { base: b0, dir, .. } => cands.push((mode::PER, *b0 + *dir * (bp - *b0).dot(*dir))),
|
||||
Prim::Ellipse(e) => cands.extend(curves::ellipse_perpendiculars(e, bp).into_iter().map(|q| (mode::PER, q))),
|
||||
Prim::Spline(s) => cands.extend(curves::spline_perpendiculars(s, bp).into_iter().map(|q| (mode::PER, q))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if osmode & mode::TAN != 0 {
|
||||
let pts = match p {
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius).tangent_points(bp),
|
||||
Prim::Circle(c) => c.tangent_points(bp),
|
||||
Prim::Ellipse(e) => curves::ellipse_tangents(e, bp),
|
||||
Prim::Spline(s) => curves::spline_tangents(s, bp),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
cands.extend(pts.into_iter().map(|t| (mode::TAN, t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = cands.iter().filter(|(_, p)| p.dist(cursor) <= aperture).min_by(|a, b| {
|
||||
// Prefer more specific modes when nearly equidistant.
|
||||
let da = a.1.dist(cursor) - if a.0 == mode::INT || a.0 == mode::END { aperture * 0.15 } else { 0.0 };
|
||||
let db = b.1.dist(cursor) - if b.0 == mode::INT || b.0 == mode::END { aperture * 0.15 } else { 0.0 };
|
||||
da.total_cmp(&db)
|
||||
});
|
||||
if let Some((m, p)) = best {
|
||||
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), deferred: None });
|
||||
}
|
||||
if base.is_none()
|
||||
&& deferred
|
||||
&& let Some(h) = deferred_hit(near_prims, cursor, aperture, osmode)
|
||||
{
|
||||
return Some(h);
|
||||
}
|
||||
if osmode & mode::NEA != 0 {
|
||||
// The closest point of all nearby objects, whatever their drawing order.
|
||||
let mut nb: Option<(f64, Vec2)> = None;
|
||||
let mut consider = |c: Vec2| {
|
||||
let dd = c.dist(cursor);
|
||||
if dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) {
|
||||
nb = Some((dd, c));
|
||||
}
|
||||
};
|
||||
for p in near_prims {
|
||||
for s in prim_segments(p) {
|
||||
consider(fix_circle(&s).closest(cursor));
|
||||
}
|
||||
if let Prim::Circle(c) = p {
|
||||
consider(c.closest(cursor));
|
||||
}
|
||||
}
|
||||
if let Some((_, p)) = nb {
|
||||
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", deferred: None });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Upper bound on the block-reference objects one snap query looks at (hostile files).
|
||||
const MAX_BLOCK_LEAVES: usize = 20_000;
|
||||
|
||||
/// Collects snap candidates and the primitives near the cursor.
|
||||
struct Sink<'a> {
|
||||
osmode: u32,
|
||||
aperture: f64,
|
||||
probe: Bounds2,
|
||||
cands: &'a mut Vec<(u32, Vec2)>,
|
||||
near: &'a mut Vec<Prim>,
|
||||
}
|
||||
|
||||
impl Sink<'_> {
|
||||
/// The snap points of one object; `xf` places an object from inside a block reference
|
||||
/// (whose primitives far from the cursor are skipped).
|
||||
fn object(&mut self, kind: &EntityKind, xf: Option<&Mat3>) {
|
||||
let osmode = self.osmode;
|
||||
// OSNAPHATCH = 0: hatches are left out.
|
||||
if osmode & mode::HATCH == 0 && matches!(kind, EntityKind::Hatch(_)) {
|
||||
return;
|
||||
}
|
||||
let at = |p: Vec2| xf.map_or(p, |m| m.apply(p));
|
||||
if osmode & mode::END != 0
|
||||
&& let EntityKind::Solid(s) | EntityKind::Trace(s) = kind
|
||||
{
|
||||
self.cands.extend(s.corners.iter().map(|c| (mode::END, at(c.xy()))));
|
||||
}
|
||||
for p in kind.prims() {
|
||||
let p = match xf {
|
||||
Some(m) => {
|
||||
let p = xform_prim(&p, m);
|
||||
if !prim_near(&p, &self.probe, self.aperture) {
|
||||
continue;
|
||||
}
|
||||
p
|
||||
}
|
||||
None => p,
|
||||
};
|
||||
let cands = &mut *self.cands;
|
||||
match &p {
|
||||
Prim::Seg(s) => {
|
||||
if osmode & mode::END != 0 {
|
||||
@@ -213,14 +391,23 @@ pub(crate) fn osnap_with(
|
||||
cands.push((mode::CEN, el.center));
|
||||
}
|
||||
if osmode & mode::QUA != 0 {
|
||||
// Only the quadrant points the arc passes through.
|
||||
for k in 0..4 {
|
||||
cands.push((mode::QUA, el.at_param(k as f64 * PI / 2.0)));
|
||||
let t = k as f64 * PI / 2.0;
|
||||
if cadcraft_geom::angle_in_sweep(t, el.start, el.end) {
|
||||
cands.push((mode::QUA, el.at_param(t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if osmode & mode::END != 0 && !el.is_full() {
|
||||
cands.push((mode::END, el.at_param(el.start)));
|
||||
cands.push((mode::END, el.at_param(el.end)));
|
||||
}
|
||||
if osmode & mode::MID != 0
|
||||
&& let Some(m) = curves::ellipse_mid(el)
|
||||
{
|
||||
cands.push((mode::MID, m));
|
||||
}
|
||||
}
|
||||
Prim::Spline(s) => {
|
||||
if osmode & mode::END != 0 && !s.closed {
|
||||
@@ -228,109 +415,212 @@ pub(crate) fn osnap_with(
|
||||
cands.push((mode::END, s.eval(lo)));
|
||||
cands.push((mode::END, s.eval(hi)));
|
||||
}
|
||||
if osmode & mode::MID != 0
|
||||
&& let Some(m) = curves::spline_mid(s)
|
||||
{
|
||||
cands.push((mode::MID, m));
|
||||
}
|
||||
}
|
||||
Prim::Point(pt) => {
|
||||
if osmode & mode::NOD != 0 && matches!(e.kind, EntityKind::Point(_)) {
|
||||
if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) {
|
||||
cands.push((mode::NOD, *pt));
|
||||
}
|
||||
}
|
||||
Prim::Infinite { base, .. } => {
|
||||
if osmode & mode::END != 0 && matches!(e.kind, EntityKind::Ray(_)) {
|
||||
if osmode & mode::END != 0 && matches!(kind, EntityKind::Ray(_)) {
|
||||
cands.push((mode::END, *base));
|
||||
}
|
||||
}
|
||||
Prim::Fill(_) => {}
|
||||
}
|
||||
near_prims.push(p);
|
||||
self.near.push(p);
|
||||
}
|
||||
if osmode & mode::GCEN != 0
|
||||
&& let EntityKind::LwPolyline(pl) = &e.kind
|
||||
&& let EntityKind::LwPolyline(pl) = kind
|
||||
&& pl.closed
|
||||
{
|
||||
let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(aperture / 10.0);
|
||||
let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0);
|
||||
if let Some(c) = centroid(&pts) {
|
||||
cands.push((mode::GCEN, c));
|
||||
self.cands.push((mode::GCEN, at(c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Intersections between nearby primitives.
|
||||
if osmode & (mode::INT | mode::APP) != 0 {
|
||||
let segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
|
||||
for i in 0..segs.len() {
|
||||
for j in i + 1..segs.len() {
|
||||
for a in segs.get(i).into_iter().flatten() {
|
||||
for b in segs.get(j).into_iter().flatten() {
|
||||
for x in intersect_ext(&fix_circle(a), &fix_circle(b), false) {
|
||||
if x.dist(cursor) <= aperture {
|
||||
cands.push((mode::INT, x));
|
||||
}
|
||||
|
||||
impl Sink<'_> {
|
||||
/// A dimension snaps to what it draws, as in AutoCAD (into its block): the objects of its
|
||||
/// block when the drawing has one (read from a file), else the lines generated from it
|
||||
/// (Endpoint on their ends); Node on its definition points.
|
||||
fn dimension(&mut self, d: &Drawing, dm: &cadcraft_doc::Dimension, budget: &mut usize) {
|
||||
match dm.block.as_ref().and_then(|n| d.block(n)) {
|
||||
Some(blk) => {
|
||||
// The block holds world geometry: a reference at its own base point places it as is.
|
||||
let ins = Insert {
|
||||
block: blk.name.clone(),
|
||||
insert: blk.base,
|
||||
scale: cadcraft_geom::Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, &mut |k, m| self.object(k, Some(m)));
|
||||
}
|
||||
None => {
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
for line in &g.lines {
|
||||
let (Some(a), Some(b)) = (line.first(), line.last()) else { continue };
|
||||
if self.osmode & mode::END != 0 {
|
||||
self.cands.extend([(mode::END, *a), (mode::END, *b)]);
|
||||
}
|
||||
if self.osmode & mode::MID != 0 && line.len() == 2 {
|
||||
self.cands.push((mode::MID, (*a + *b) * 0.5));
|
||||
}
|
||||
for w in line.windows(2) {
|
||||
if let [p, q] = w {
|
||||
let s = Prim::Seg(Segment::Line(cadcraft_geom::Line::new(*p, *q)));
|
||||
if prim_near(&s, &self.probe, self.aperture) {
|
||||
self.near.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.osmode & mode::NOD != 0 {
|
||||
self.cands.extend(dim_def_points(dm).into_iter().map(|p| (mode::NOD, p)));
|
||||
}
|
||||
}
|
||||
// Perpendicular / tangent from the base point.
|
||||
if let Some(bp) = base {
|
||||
for p in &near_prims {
|
||||
if osmode & mode::PER != 0 {
|
||||
match p {
|
||||
Prim::Seg(Segment::Line(l)) => cands.push((mode::PER, l.project(bp))),
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => cands.push((mode::PER, Circle::new(arc.center, arc.radius).closest(bp))),
|
||||
Prim::Circle(c) => cands.push((mode::PER, c.closest(bp))),
|
||||
Prim::Infinite { base: b0, dir, .. } => cands.push((mode::PER, *b0 + *dir * (bp - *b0).dot(*dir))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition points of a dimension that its kind uses.
|
||||
fn dim_def_points(dm: &cadcraft_doc::Dimension) -> Vec<Vec2> {
|
||||
use cadcraft_doc::DimKind;
|
||||
let pts = match dm.kind {
|
||||
DimKind::Linear { .. } | DimKind::Aligned | DimKind::ArcLength => vec![dm.defpt, dm.p13, dm.p14],
|
||||
DimKind::Radius | DimKind::Diameter => vec![dm.defpt, dm.p15],
|
||||
DimKind::Angular3P => vec![dm.defpt, dm.p13, dm.p14, dm.p15],
|
||||
DimKind::Angular => vec![dm.defpt, dm.p13, dm.p14, dm.p15, dm.p16],
|
||||
DimKind::Ordinate { .. } => vec![dm.p13, dm.p14],
|
||||
};
|
||||
pts.into_iter().map(|p| p.xy()).filter(|p| p.is_finite()).collect()
|
||||
}
|
||||
|
||||
/// Calls `f` with each object drawn by a block reference and the transform placing it, nested
|
||||
/// references expanded (within [`MAX_BLOCK_DEPTH`] levels and `budget` objects); objects that
|
||||
/// aren't drawn (hidden, on a layer that is off or frozen, attribute definitions) are left out.
|
||||
fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut usize, f: &mut dyn FnMut(&EntityKind, &Mat3)) {
|
||||
if depth >= MAX_BLOCK_DEPTH {
|
||||
return;
|
||||
}
|
||||
let Some(blk) = d.block(&ins.block) else { return };
|
||||
if blk.entities.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (cols, rows) = (ins.cols.clamp(1, 10_000), ins.rows.clamp(1, 10_000));
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
// Each array cell spends budget too, so a huge array of tiny blocks stays bounded.
|
||||
if *budget == 0 {
|
||||
return;
|
||||
}
|
||||
if osmode & mode::TAN != 0 {
|
||||
let circ = match p {
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => Some(Circle::new(arc.center, arc.radius)),
|
||||
Prim::Circle(c) => Some(*c),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(c) = circ {
|
||||
cands.extend(c.tangent_points(bp).into_iter().map(|t| (mode::TAN, t)));
|
||||
*budget -= 1;
|
||||
let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation);
|
||||
let mm = m.then_before(Mat3::translate(off).then_before(ins.transform(blk.base.xy())));
|
||||
for be in blk.entities.iter() {
|
||||
if *budget == 0 {
|
||||
return;
|
||||
}
|
||||
*budget -= 1;
|
||||
// Objects on layer 0 take the reference's layer, which is visible.
|
||||
if !be.common.visible || (be.common.layer != "0" && !d.is_visible(be)) {
|
||||
continue;
|
||||
}
|
||||
match &be.kind {
|
||||
EntityKind::AttDef(_) => {}
|
||||
EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, f),
|
||||
k => f(k, &mm),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = cands.iter().filter(|(_, p)| p.dist(cursor) <= aperture).min_by(|a, b| {
|
||||
// Prefer more specific modes when nearly equidistant.
|
||||
let da = a.1.dist(cursor) - if a.0 == mode::INT || a.0 == mode::END { aperture * 0.15 } else { 0.0 };
|
||||
let db = b.1.dist(cursor) - if b.0 == mode::INT || b.0 == mode::END { aperture * 0.15 } else { 0.0 };
|
||||
da.total_cmp(&db)
|
||||
});
|
||||
if let Some((m, p)) = best {
|
||||
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), deferred: None });
|
||||
}
|
||||
if base.is_none()
|
||||
&& deferred
|
||||
&& let Some(h) = deferred_hit(&near_prims, cursor, aperture, osmode)
|
||||
{
|
||||
return Some(h);
|
||||
}
|
||||
if osmode & mode::NEA != 0 {
|
||||
let mut nb: Option<(f64, Vec2)> = None;
|
||||
for p in &near_prims {
|
||||
for s in prim_segments(p) {
|
||||
let c = fix_circle(&s).closest(cursor);
|
||||
let dd = c.dist(cursor);
|
||||
if dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) {
|
||||
nb = Some((dd, c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` comes within `aperture` of the box `probe`.
|
||||
fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool {
|
||||
let b = match p {
|
||||
Prim::Seg(s) => s.bounds(),
|
||||
Prim::Circle(c) => c.bounds(),
|
||||
Prim::Ellipse(e) => e.bounds(),
|
||||
Prim::Spline(s) => Bounds2::from_points(s.control.iter().copied()),
|
||||
Prim::Point(q) => Bounds2::new(*q, *q),
|
||||
Prim::Infinite { .. } => return true,
|
||||
Prim::Fill(f) => Bounds2::from_points(f.iter().copied()),
|
||||
};
|
||||
b.expand(aperture).intersects(probe)
|
||||
}
|
||||
|
||||
/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None`
|
||||
/// when it distorts shapes (non-uniform scale or shear).
|
||||
fn similarity_scale(m: &Mat3) -> Option<f64> {
|
||||
let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d));
|
||||
let (lu, lv) = (u.len(), v.len());
|
||||
let tol = 1e-9 * lu.max(lv);
|
||||
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
|
||||
}
|
||||
|
||||
/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale
|
||||
/// become ellipses and elliptical arcs.
|
||||
fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
|
||||
let mirror = m.is_mirroring();
|
||||
match p {
|
||||
Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))),
|
||||
Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) {
|
||||
Some(s) => {
|
||||
let c = m.apply(arc.center);
|
||||
let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point())));
|
||||
let (start, end) = if mirror { (a1, a0) } else { (a0, a1) };
|
||||
Prim::Seg(Segment::Arc { arc: cadcraft_geom::Arc::new(c, arc.radius * s, start, end), ccw: *ccw != mirror })
|
||||
}
|
||||
if let Prim::Circle(c) = p {
|
||||
let q = c.closest(cursor);
|
||||
if q.dist(cursor) <= aperture {
|
||||
nb = Some((q.dist(cursor), q));
|
||||
}
|
||||
None => {
|
||||
let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end };
|
||||
Prim::Ellipse(xform_ellipse(&e, m))
|
||||
}
|
||||
},
|
||||
Prim::Circle(c) => match similarity_scale(m) {
|
||||
Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)),
|
||||
None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)),
|
||||
},
|
||||
Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)),
|
||||
Prim::Spline(s) => {
|
||||
let mut s = s.clone();
|
||||
s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q));
|
||||
Prim::Spline(s)
|
||||
}
|
||||
if let Some((_, p)) = nb {
|
||||
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", deferred: None });
|
||||
}
|
||||
Prim::Point(q) => Prim::Point(m.apply(*q)),
|
||||
Prim::Infinite { base, dir, ray } => Prim::Infinite { base: m.apply(*base), dir: m.apply_vec(*dir).normalized(), ray: *ray },
|
||||
Prim::Fill(f) => Prim::Fill(f.iter().map(|q| m.apply(*q)).collect()),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate
|
||||
/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them
|
||||
/// (and reverse under a mirroring transform).
|
||||
fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse {
|
||||
let center = m.apply(e.center);
|
||||
let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor()));
|
||||
// |am·cos t + an·sin t| is largest at t0: the new major axis.
|
||||
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
|
||||
let (s0, c0) = t0.sin_cos();
|
||||
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
|
||||
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
|
||||
if e.is_full() {
|
||||
return Ellipse::full(center, u, ratio);
|
||||
}
|
||||
let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) };
|
||||
Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) }
|
||||
}
|
||||
|
||||
/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`.
|
||||
@@ -527,11 +817,21 @@ pub fn polar(base: Vec2, p: Vec2, inc: f64, tol: f64) -> Option<(Vec2, f64)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid snap.
|
||||
pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2) -> Vec2 {
|
||||
let sx = if unit.x > 1e-12 { ((p.x - origin.x) / unit.x).round() * unit.x + origin.x } else { p.x };
|
||||
let sy = if unit.y > 1e-12 { ((p.y - origin.y) / unit.y).round() * unit.y + origin.y } else { p.y };
|
||||
Vec2::new(sx, sy)
|
||||
/// Grid snap: the nearest point of the grid of `unit` spacing through `origin`, turned by
|
||||
/// `angle` (radians) about it.
|
||||
pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2, angle: f64) -> Vec2 {
|
||||
let angle = if angle.is_finite() { angle } else { 0.0 };
|
||||
let q = (p - origin).rotate(-angle);
|
||||
let sx = if unit.x > 1e-12 { (q.x / unit.x).round() * unit.x } else { q.x };
|
||||
let sy = if unit.y > 1e-12 { (q.y / unit.y).round() * unit.y } else { q.y };
|
||||
let s = origin + Vec2::new(sx, sy).rotate(angle);
|
||||
if s.is_finite() { s } else { p }
|
||||
}
|
||||
|
||||
/// The snap grid's origin (SNAPBASE) and rotation in radians (SNAPANG, in degrees) of a drawing.
|
||||
pub fn grid_frame(d: &Drawing) -> (Vec2, f64) {
|
||||
let base = d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO);
|
||||
(base, d.header.f64("SNAPANG", 0.0).to_radians())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -589,7 +889,7 @@ mod tests {
|
||||
let (p, a) = polar(Vec2::ZERO, Vec2::new(5.0, 5.1), 45f64.to_radians(), 3f64.to_radians()).unwrap();
|
||||
assert!((a - PI / 4.0).abs() < 1e-12);
|
||||
assert!((p.x - p.y).abs() < 1e-9);
|
||||
assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO), Vec2::new(0.5, 1.5));
|
||||
assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO, 0.0), Vec2::new(0.5, 1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Snap points on ellipses, elliptical arcs and splines: the midpoint by length, and the
|
||||
//! perpendicular feet and tangent points from a base point. Splines (and perpendiculars on
|
||||
//! ellipses) are solved numerically: the parameter range is sampled, each sign change of the
|
||||
//! condition is narrowed by bisection, and every loop has a fixed bound.
|
||||
|
||||
use cadcraft_geom::{Circle, Ellipse, Spline, Vec2, angle_in_sweep};
|
||||
|
||||
/// Parameter samples along a curve (sign changes and length).
|
||||
const SAMPLES: usize = 512;
|
||||
/// Bisection steps per root (the bracket shrinks to 2⁻⁶⁰ of a sample step).
|
||||
const BISECT: usize = 60;
|
||||
/// The most solutions one curve offers.
|
||||
const MAX_ROOTS: usize = 16;
|
||||
|
||||
/// A curve given by a parameter range, its points and its derivative.
|
||||
struct Param<'a> {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
at: &'a dyn Fn(f64) -> Vec2,
|
||||
d: &'a dyn Fn(f64) -> Vec2,
|
||||
}
|
||||
|
||||
impl Param<'_> {
|
||||
fn ellipse(e: &Ellipse, f: impl FnOnce(&Param) -> Vec<Vec2>) -> Vec<Vec2> {
|
||||
let (u, v) = (e.major, e.minor());
|
||||
let lo = e.start;
|
||||
let hi = e.start + e.sweep();
|
||||
f(&Param { lo, hi, at: &|t| e.at_param(t), d: &|t| v * t.cos() - u * t.sin() })
|
||||
}
|
||||
|
||||
fn spline(s: &Spline, f: impl FnOnce(&Param) -> Vec<Vec2>) -> Vec<Vec2> {
|
||||
let (lo, hi) = s.domain();
|
||||
if !(s.is_valid() && lo.is_finite() && hi.is_finite() && hi > lo) {
|
||||
return Vec::new();
|
||||
}
|
||||
let h = (hi - lo) * 1e-7;
|
||||
let d = |t: f64| {
|
||||
let (a, b) = ((t - h).max(lo), (t + h).min(hi));
|
||||
if b > a { (s.eval(b) - s.eval(a)) / (b - a) } else { Vec2::ZERO }
|
||||
};
|
||||
f(&Param { lo, hi, at: &|t| s.eval(t), d: &d })
|
||||
}
|
||||
|
||||
fn param(&self, i: usize) -> f64 {
|
||||
self.lo + (self.hi - self.lo) * i as f64 / SAMPLES as f64
|
||||
}
|
||||
|
||||
/// The point halfway along the curve's length.
|
||||
fn mid(&self) -> Option<Vec2> {
|
||||
let pts: Vec<Vec2> = (0..=SAMPLES).map(|i| (self.at)(self.param(i))).collect();
|
||||
let lens: Vec<f64> = pts.windows(2).map(|w| if let [a, b] = w { a.dist(*b) } else { 0.0 }).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut run = 0.0;
|
||||
for (i, l) in lens.iter().enumerate() {
|
||||
if run + l >= total / 2.0 && *l > 0.0 {
|
||||
let k = (total / 2.0 - run) / l;
|
||||
let t = self.param(i) + (self.param(i + 1) - self.param(i)) * k;
|
||||
return Some((self.at)(t)).filter(|p| p.is_finite());
|
||||
}
|
||||
run += l;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The points where `cond(point − base, derivative)` is zero: sign changes of the samples,
|
||||
/// narrowed by bisection; kept when the normalised condition is (nearly) zero there, so a
|
||||
/// sign flip at a kink or a pole is not taken for a solution.
|
||||
fn solve(&self, base: Vec2, cond: fn(Vec2, Vec2) -> f64) -> Vec<Vec2> {
|
||||
let g = |t: f64| cond((self.at)(t) - base, (self.d)(t));
|
||||
let samples: Vec<(Vec2, Vec2)> = (0..=SAMPLES).map(|i| ((self.at)(self.param(i)) - base, (self.d)(self.param(i)))).collect();
|
||||
let vals: Vec<f64> = samples.iter().map(|(r, d)| cond(*r, *d)).collect();
|
||||
// A curve on which every point qualifies (a circle seen from its centre) has no
|
||||
// particular solution.
|
||||
let scale = samples.iter().map(|(r, d)| r.len() * d.len()).fold(0.0, f64::max);
|
||||
if !(scale.is_finite() && scale > 0.0) || vals.iter().all(|v| v.abs() <= scale * 1e-12) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out: Vec<Vec2> = Vec::new();
|
||||
for i in 0..SAMPLES {
|
||||
let (Some(&ga), Some(&gb)) = (vals.get(i), vals.get(i + 1)) else { break };
|
||||
if !(ga.is_finite() && gb.is_finite()) || (ga > 0.0) == (gb > 0.0) && ga != 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (mut a, mut b, mut fa) = (self.param(i), self.param(i + 1), ga);
|
||||
for _ in 0..BISECT {
|
||||
let m = 0.5 * (a + b);
|
||||
let fm = g(m);
|
||||
if fm == 0.0 {
|
||||
(a, b) = (m, m);
|
||||
break;
|
||||
}
|
||||
if (fm > 0.0) == (fa > 0.0) {
|
||||
(a, fa) = (m, fm);
|
||||
} else {
|
||||
b = m;
|
||||
}
|
||||
}
|
||||
let t = 0.5 * (a + b);
|
||||
let (p, dv) = ((self.at)(t), (self.d)(t));
|
||||
let (r, dl) = ((p - base).len(), dv.len());
|
||||
if !(p.is_finite() && r > 1e-12 && dl > 0.0) || cond(p - base, dv).abs() > 1e-6 * r * dl {
|
||||
continue;
|
||||
}
|
||||
if !out.iter().any(|q| q.near(p, 1e-9 * (1.0 + r))) {
|
||||
out.push(p);
|
||||
if out.len() >= MAX_ROOTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn perpendicular(r: Vec2, d: Vec2) -> f64 {
|
||||
r.dot(d)
|
||||
}
|
||||
|
||||
fn tangent(r: Vec2, d: Vec2) -> f64 {
|
||||
r.cross(d)
|
||||
}
|
||||
|
||||
/// The midpoint (by length) of an elliptical arc; none for a full ellipse.
|
||||
pub(super) fn ellipse_mid(e: &Ellipse) -> Option<Vec2> {
|
||||
if e.is_full() {
|
||||
return None;
|
||||
}
|
||||
Param::ellipse(e, |c| c.mid().into_iter().collect()).first().copied()
|
||||
}
|
||||
|
||||
/// The feet of the perpendiculars from `base` to an ellipse or elliptical arc.
|
||||
pub(super) fn ellipse_perpendiculars(e: &Ellipse, base: Vec2) -> Vec<Vec2> {
|
||||
Param::ellipse(e, |c| c.solve(base, perpendicular))
|
||||
}
|
||||
|
||||
/// The points where lines from `base` touch an ellipse or elliptical arc: exact, by mapping the
|
||||
/// ellipse onto the unit circle (an affine map keeps tangency).
|
||||
pub(super) fn ellipse_tangents(e: &Ellipse, base: Vec2) -> Vec<Vec2> {
|
||||
let (u, v) = (e.major, e.minor());
|
||||
let (a, b) = (u.len(), v.len());
|
||||
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let r = base - e.center;
|
||||
let local = Vec2::new(r.dot(u) / (a * a), r.dot(v) / (b * b));
|
||||
Circle::new(Vec2::ZERO, 1.0)
|
||||
.tangent_points(local)
|
||||
.into_iter()
|
||||
.map(|q| q.angle())
|
||||
.filter(|t| t.is_finite() && angle_in_sweep(*t, e.start, e.end))
|
||||
.map(|t| e.at_param(t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The midpoint (by length) of an open spline.
|
||||
pub(super) fn spline_mid(s: &Spline) -> Option<Vec2> {
|
||||
if s.closed {
|
||||
return None;
|
||||
}
|
||||
Param::spline(s, |c| c.mid().into_iter().collect()).first().copied()
|
||||
}
|
||||
|
||||
/// The feet of the perpendiculars from `base` to a spline.
|
||||
pub(super) fn spline_perpendiculars(s: &Spline, base: Vec2) -> Vec<Vec2> {
|
||||
Param::spline(s, |c| c.solve(base, perpendicular))
|
||||
}
|
||||
|
||||
/// The points where lines from `base` touch a spline.
|
||||
pub(super) fn spline_tangents(s: &Spline, base: Vec2) -> Vec<Vec2> {
|
||||
Param::spline(s, |c| c.solve(base, tangent))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_geom::PI;
|
||||
|
||||
fn length(f: impl Fn(f64) -> Vec2, lo: f64, hi: f64) -> f64 {
|
||||
let n = 20_000;
|
||||
(0..n).map(|i| f(lo + (hi - lo) * i as f64 / n as f64).dist(f(lo + (hi - lo) * (i + 1) as f64 / n as f64))).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_mid_halves_the_length() {
|
||||
let e = Ellipse { center: Vec2::new(1.0, 2.0), major: Vec2::new(4.0, 0.0), ratio: 0.5, start: 0.0, end: PI / 2.0 };
|
||||
let m = ellipse_mid(&e).unwrap();
|
||||
let t = e.param_of(m);
|
||||
let (l1, l2) = (length(|t| e.at_param(t), 0.0, t), length(|t| e.at_param(t), t, PI / 2.0));
|
||||
assert!((l1 - l2).abs() < 1e-4, "{l1} vs {l2}");
|
||||
assert!(ellipse_mid(&Ellipse::full(Vec2::ZERO, Vec2::X, 0.5)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_tangents_touch_and_stay_on_the_arc() {
|
||||
let full = Ellipse::full(Vec2::ZERO, Vec2::new(4.0, 0.0), 0.5);
|
||||
let base = Vec2::new(0.0, 5.0);
|
||||
let ts = ellipse_tangents(&full, base);
|
||||
assert_eq!(ts.len(), 2);
|
||||
for p in &ts {
|
||||
// On the ellipse, and the line from the base touches it: (x/a)² + (y/b)² = 1 and
|
||||
// the gradient is perpendicular to the line.
|
||||
assert!(((p.x / 4.0).powi(2) + (p.y / 2.0).powi(2) - 1.0).abs() < 1e-9);
|
||||
let grad = Vec2::new(p.x / 16.0, p.y / 4.0);
|
||||
assert!(grad.dot(*p - base).abs() < 1e-9);
|
||||
}
|
||||
// Only the right half of the upper arc: one tangent point.
|
||||
let arc = Ellipse { start: 0.0, end: PI / 2.0, ..full };
|
||||
assert_eq!(ellipse_tangents(&arc, base).len(), 1);
|
||||
// From inside: none.
|
||||
assert!(ellipse_tangents(&full, Vec2::new(1.0, 0.5)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_perpendiculars_are_normals() {
|
||||
let e = Ellipse::full(Vec2::ZERO, Vec2::new(4.0, 0.0), 0.5);
|
||||
let base = Vec2::new(1.0, 0.5);
|
||||
let ps = ellipse_perpendiculars(&e, base);
|
||||
// From a point inside near the centre: the four normals of an ellipse.
|
||||
assert_eq!(ps.len(), 4, "{ps:?}");
|
||||
for p in &ps {
|
||||
let tan = Vec2::new(-p.y * 4.0 / 2.0, p.x * 2.0 / 4.0);
|
||||
assert!(tan.dot(*p - base).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
// A circle seen from its centre has no particular perpendicular.
|
||||
assert!(ellipse_perpendiculars(&Ellipse::full(Vec2::ZERO, Vec2::X, 1.0), Vec2::ZERO).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_snaps() {
|
||||
let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 3.0), Vec2::new(3.0, 3.0), Vec2::new(4.0, 0.0)], 3);
|
||||
// Symmetric about x = 2: the midpoint is on the axis, so is the perpendicular from
|
||||
// straight below, and the tangents from far above are symmetric.
|
||||
let m = spline_mid(&s).unwrap();
|
||||
assert!((m.x - 2.0).abs() < 1e-6 && (m.y - 2.25).abs() < 1e-6, "{m:?}");
|
||||
let ps = spline_perpendiculars(&s, Vec2::new(2.0, -5.0));
|
||||
assert!(ps.iter().any(|p| p.near(m, 1e-6)), "{ps:?}");
|
||||
// Every tangent of the arch passes x = 2 between y = 2.25 and 6: none from (2, 10) or
|
||||
// from below the arch, two from (2, 4).
|
||||
assert!(spline_tangents(&s, Vec2::new(2.0, 10.0)).is_empty());
|
||||
assert!(spline_tangents(&s, Vec2::new(2.0, -1.0)).is_empty());
|
||||
let ts = spline_tangents(&s, Vec2::new(2.0, 4.0));
|
||||
assert_eq!(ts.len(), 2, "{ts:?}");
|
||||
assert!((ts[0].x - 2.0 + ts[1].x - 2.0).abs() < 1e-6 && (ts[0].y - ts[1].y).abs() < 1e-6, "{ts:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! Object snap tracking (OTRACK, F11), polar tracking paths and the snaps that work through
|
||||
//! acquired points: Extension (EXT), Parallel (PAR) and extended Apparent Intersection (APP).
|
||||
//!
|
||||
//! Resting the cursor on an object snap point for [`DWELL`] seconds *acquires* it (the UI marks
|
||||
//! it with a small `+`); resting on it again releases it. Up to [`MAX_ACQUIRED`] points are
|
||||
//! kept, the oldest dropped first. Acquired points send alignment paths: horizontal and vertical,
|
||||
//! or every polar angle when POLARMODE bit 2 says so. With EXT running, an acquired line or arc
|
||||
//! endpoint also sends the object's extension; with PAR, a line rested on gives a path parallel
|
||||
//! to it through the base point; with APP, an object rested on gives its whole extension, so
|
||||
//! where it would meet another object snaps as an Apparent Intersection.
|
||||
//!
|
||||
//! [`Tracker::track`] then finds, near the cursor, in this order: the intersection of two paths,
|
||||
//! a path crossing an object, or the nearest point on one path. Everything here is plain data
|
||||
//! and pure functions over the drawing, so scripts and tests drive it without the UI
|
||||
//! ([`crate::Session::snap_cursor`]).
|
||||
|
||||
use cadcraft_doc::{Drawing, Prim, Space};
|
||||
use cadcraft_geom::{Line, PI, Segment, TAU, Vec2, angle_in_sweep, intersect_ext, norm_angle};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{SnapHit, mode};
|
||||
|
||||
/// The most points kept acquired (as AutoCAD: seven).
|
||||
pub const MAX_ACQUIRED: usize = 7;
|
||||
/// Seconds the cursor rests on a point before it is acquired (or released).
|
||||
pub const DWELL: f64 = 0.4;
|
||||
/// The most polar angles one path source sends (a tiny POLARANG is hostile input).
|
||||
const MAX_ANGLES: usize = 360;
|
||||
/// The most paths looked at near the cursor.
|
||||
const MAX_NEAR_PATHS: usize = 64;
|
||||
|
||||
/// POLARMODE bits.
|
||||
pub mod polarmode {
|
||||
/// Polar angles measured relative to the last segment (not modelled yet).
|
||||
pub const RELATIVE: u32 = 1;
|
||||
/// Object snap tracking uses every polar angle, not only horizontal and vertical.
|
||||
pub const TRACK_POLAR: u32 = 2;
|
||||
/// The additional polar angles (POLARADDANG) are tracked too.
|
||||
pub const ADDITIONAL: u32 = 4;
|
||||
/// Points are acquired by holding Shift, not by resting the cursor on them.
|
||||
pub const SHIFT_ACQUIRE: u32 = 8;
|
||||
}
|
||||
|
||||
/// The shape of a tracking path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum PathShape {
|
||||
/// From `origin` along the unit direction `dir`.
|
||||
Ray { origin: Vec2, dir: Vec2 },
|
||||
/// The whole line through `origin` along `dir`.
|
||||
Line { origin: Vec2, dir: Vec2 },
|
||||
/// The circle an arc lies on, without the arc itself (`gap`: its start and end angles, the
|
||||
/// arc running counterclockwise from one to the other) when it is the arc's extension.
|
||||
Circle { center: Vec2, radius: f64, gap: Option<(f64, f64)> },
|
||||
}
|
||||
|
||||
impl PathShape {
|
||||
/// The closest point of the path to `p`, if any part of the path is closest there.
|
||||
pub fn foot(&self, p: Vec2) -> Option<Vec2> {
|
||||
let f = match *self {
|
||||
PathShape::Ray { origin, dir } => {
|
||||
let t = (p - origin).dot(dir);
|
||||
(t > 0.0).then(|| origin + dir * t)?
|
||||
}
|
||||
PathShape::Line { origin, dir } => origin + dir * (p - origin).dot(dir),
|
||||
PathShape::Circle { center, radius, .. } => {
|
||||
let u = (p - center).normalized();
|
||||
if u == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
center + u * radius
|
||||
}
|
||||
};
|
||||
(f.is_finite() && self.contains(f)).then_some(f)
|
||||
}
|
||||
|
||||
/// Whether `p` (a point of the path's line or circle) is on the path.
|
||||
fn contains(&self, p: Vec2) -> bool {
|
||||
match *self {
|
||||
PathShape::Ray { origin, dir } => (p - origin).dot(dir) >= -cadcraft_geom::EPS,
|
||||
PathShape::Line { .. } => true,
|
||||
PathShape::Circle { center, gap, .. } => gap.is_none_or(|(s, e)| !angle_in_sweep(center.angle_to(p), s, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The path's line or circle as a segment to intersect (`intersect_ext` extends it).
|
||||
fn segment(&self) -> Segment {
|
||||
match *self {
|
||||
PathShape::Ray { origin, dir } | PathShape::Line { origin, dir } => Segment::Line(Line::new(origin, origin + dir)),
|
||||
PathShape::Circle { center, radius, .. } => Segment::Arc { arc: cadcraft_geom::Arc::new(center, radius, 0.0, PI), ccw: true },
|
||||
}
|
||||
}
|
||||
|
||||
fn is_finite(&self) -> bool {
|
||||
match *self {
|
||||
PathShape::Ray { origin, dir } | PathShape::Line { origin, dir } => origin.is_finite() && dir.is_finite() && dir != Vec2::ZERO,
|
||||
PathShape::Circle { center, radius, .. } => center.is_finite() && radius.is_finite() && radius > cadcraft_geom::EPS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What sent a tracking path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathKind {
|
||||
/// Polar tracking from the base point.
|
||||
Polar,
|
||||
/// Object snap tracking from an acquired point.
|
||||
Alignment,
|
||||
/// The extension of a line or arc from its acquired endpoint (EXT).
|
||||
Extension,
|
||||
/// Parallel to an acquired line, through the base point (PAR).
|
||||
Parallel,
|
||||
/// The whole extension of an acquired object (APP): only its crossings snap.
|
||||
Apparent,
|
||||
}
|
||||
|
||||
/// One tracking path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Path {
|
||||
pub shape: PathShape,
|
||||
pub kind: PathKind,
|
||||
/// The point it is measured from (tooltips, direct distance entry): the acquired point, or
|
||||
/// the base point.
|
||||
pub from: Vec2,
|
||||
/// The tooltip name: the acquired point's snap ("Endpoint" …), "Polar", "Extension",
|
||||
/// "Parallel" or "Apparent Intersection".
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
impl Path {
|
||||
/// Whether the cursor slides along this path (an Apparent Intersection path only snaps
|
||||
/// where it crosses something).
|
||||
fn slides(&self) -> bool {
|
||||
self.kind != PathKind::Apparent
|
||||
}
|
||||
}
|
||||
|
||||
/// A point acquired for tracking.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Acquired {
|
||||
/// An object snap point; `ext` is the extension path when it is a line or arc endpoint.
|
||||
Point {
|
||||
at: Vec2,
|
||||
name: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ext: Option<PathShape>,
|
||||
},
|
||||
/// A line rested on with Parallel running: `at` where, `dir` its unit direction.
|
||||
Parallel { at: Vec2, dir: Vec2 },
|
||||
/// An object rested on with Apparent Intersection running, and its whole extension.
|
||||
Apparent { at: Vec2, shape: PathShape },
|
||||
}
|
||||
|
||||
impl Acquired {
|
||||
/// Where the acquisition marker goes.
|
||||
pub fn at(&self) -> Vec2 {
|
||||
match *self {
|
||||
Acquired::Point { at, .. } | Acquired::Parallel { at, .. } | Acquired::Apparent { at, .. } => at,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same point or object (so resting on it again releases it).
|
||||
fn same(&self, o: &Acquired) -> bool {
|
||||
let near = |a: Vec2, b: Vec2| a.dist(b) <= 1e-9 * (1.0 + a.len().max(b.len()));
|
||||
match (self, o) {
|
||||
(Acquired::Point { at: a, .. }, Acquired::Point { at: b, .. }) => near(*a, *b),
|
||||
(Acquired::Parallel { dir: a, at: p }, Acquired::Parallel { dir: b, at: q }) => {
|
||||
a.cross(*b).abs() < 1e-9 && (*p - *q).cross(*a).abs() <= 1e-9 * (1.0 + p.len())
|
||||
}
|
||||
(Acquired::Apparent { shape: a, .. }, Acquired::Apparent { shape: b, .. }) => same_shape(a, b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_shape(a: &PathShape, b: &PathShape) -> bool {
|
||||
match (*a, *b) {
|
||||
(PathShape::Line { origin: o1, dir: d1 }, PathShape::Line { origin: o2, dir: d2 }) => {
|
||||
d1.cross(d2).abs() < 1e-9 && (o2 - o1).cross(d1).abs() <= 1e-9 * (1.0 + o1.len())
|
||||
}
|
||||
(PathShape::Circle { center: c1, radius: r1, .. }, PathShape::Circle { center: c2, radius: r2, .. }) => {
|
||||
c1.dist(c2) <= 1e-9 * (1.0 + c1.len()) && (r1 - r2).abs() <= 1e-9 * (1.0 + r1)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings tracking reads, resolved once per cursor update.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrackConfig {
|
||||
/// OTRACK (F11): acquired points send alignment paths.
|
||||
pub otrack: bool,
|
||||
/// Running object snaps (OSMODE, with the F3 off bit).
|
||||
pub osmode: u32,
|
||||
/// Directions (radians) polar tracking follows from the base point; empty when polar is off
|
||||
/// or ortho is on.
|
||||
pub polar_angles: Vec<f64>,
|
||||
/// Directions (radians) alignment paths follow from acquired points.
|
||||
pub track_angles: Vec<f64>,
|
||||
/// POLARMODE bit 8: acquire with Shift instead of resting the cursor.
|
||||
pub shift_acquire: bool,
|
||||
}
|
||||
|
||||
impl TrackConfig {
|
||||
/// From the session settings and ANGBASE (radians), from which polar angles are measured.
|
||||
pub fn new(s: &crate::Settings, angbase: f64) -> Self {
|
||||
let polar = polar_angles(s.polarang, if s.polar_flags & polarmode::ADDITIONAL != 0 { &s.polaraddang } else { &[] }, angbase);
|
||||
let track_angles = if s.polar_flags & polarmode::TRACK_POLAR != 0 { polar.clone() } else { orthogonal() };
|
||||
TrackConfig {
|
||||
otrack: s.otrack,
|
||||
osmode: s.osmode,
|
||||
polar_angles: if s.polarmode && !s.orthomode { polar } else { Vec::new() },
|
||||
track_angles,
|
||||
shift_acquire: s.polar_flags & polarmode::SHIFT_ACQUIRE != 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn running(&self, bit: u32) -> bool {
|
||||
self.osmode & mode::OFF == 0 && self.osmode & bit != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal and vertical, both ways.
|
||||
fn orthogonal() -> Vec<f64> {
|
||||
vec![0.0, PI / 2.0, PI, 1.5 * PI]
|
||||
}
|
||||
|
||||
/// Every multiple of the increment `inc` (POLARANG) around the circle, plus the `additional`
|
||||
/// angles (POLARADDANG), all measured from `angbase`; radians, normalized, without repeats and
|
||||
/// at most [`MAX_ANGLES`] of them.
|
||||
pub fn polar_angles(inc: f64, additional: &[f64], angbase: f64) -> Vec<f64> {
|
||||
let angbase = if angbase.is_finite() { angbase } else { 0.0 };
|
||||
let mut v: Vec<f64> = Vec::new();
|
||||
let mut push = |a: f64| {
|
||||
let a = norm_angle(a + angbase);
|
||||
if v.len() < MAX_ANGLES && !v.iter().any(|b| (norm_angle(a - b + PI) - PI).abs() < 1e-9) {
|
||||
v.push(a);
|
||||
}
|
||||
};
|
||||
if inc.is_finite() && inc > 1e-6 {
|
||||
let n = ((TAU / inc) - 1e-9).ceil().clamp(1.0, MAX_ANGLES as f64) as usize;
|
||||
for k in 0..n {
|
||||
push(k as f64 * inc);
|
||||
}
|
||||
} else {
|
||||
push(0.0);
|
||||
}
|
||||
for a in additional.iter().filter(|a| a.is_finite()) {
|
||||
push(*a);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The tracking result at the cursor.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Track {
|
||||
pub point: Vec2,
|
||||
/// The paths the point lies on (one, or two where they cross).
|
||||
pub paths: Vec<Path>,
|
||||
/// Set where a path crosses an object: "Intersection", or "Apparent Intersection" for an
|
||||
/// object's extension.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// The point and unit direction a typed distance is measured from and along: the one
|
||||
/// straight path the cursor slides on (`None` at crossings and on arc extensions).
|
||||
pub fn along(&self) -> Option<(Vec2, Vec2)> {
|
||||
let [p] = self.paths.as_slice() else { return None };
|
||||
if self.object.is_some() || matches!(p.shape, PathShape::Circle { .. }) {
|
||||
return None;
|
||||
}
|
||||
let dir = (self.point - p.from).normalized();
|
||||
(dir != Vec2::ZERO && dir.is_finite()).then_some((p.from, dir))
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquired points, the acquisition in progress and the last tracking result.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tracker {
|
||||
pub acquired: Vec<Acquired>,
|
||||
/// The candidate under the cursor and when the cursor came to it.
|
||||
#[serde(skip)]
|
||||
pending: Option<(Acquired, f64)>,
|
||||
/// The candidate last acquired or released: the cursor must leave it before it toggles again.
|
||||
#[serde(skip)]
|
||||
toggled: Option<Acquired>,
|
||||
/// The result of the latest [`Tracker::track`] (direct distance entry follows its path).
|
||||
pub last: Option<Track>,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
/// Forget every acquired point (a command ends, the prompt no longer wants a point).
|
||||
pub fn clear(&mut self) {
|
||||
*self = Tracker::default();
|
||||
}
|
||||
|
||||
/// Acquire or release `cand` by hand, as resting the cursor on it does.
|
||||
pub fn toggle(&mut self, cand: Acquired) {
|
||||
if let Some(i) = self.acquired.iter().position(|a| a.same(&cand)) {
|
||||
self.acquired.remove(i);
|
||||
} else {
|
||||
self.acquired.push(cand);
|
||||
if self.acquired.len() > MAX_ACQUIRED {
|
||||
self.acquired.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor is over `cand` (or nothing) at `now` seconds: acquires or releases it once it
|
||||
/// has rested there for [`DWELL`] seconds, or at once when `shift` is held in Shift-to-acquire
|
||||
/// mode. Returns true while an acquisition is pending (the UI should redraw again soon).
|
||||
pub fn hover(&mut self, cand: Option<Acquired>, now: f64, shift_acquire: bool, shift: bool) -> bool {
|
||||
let Some(c) = cand else {
|
||||
self.pending = None;
|
||||
self.toggled = None;
|
||||
return false;
|
||||
};
|
||||
if self.toggled.is_some_and(|t| t.same(&c)) {
|
||||
return false;
|
||||
}
|
||||
self.toggled = None;
|
||||
if shift_acquire {
|
||||
self.pending = None;
|
||||
if shift {
|
||||
self.toggle(c);
|
||||
self.toggled = Some(c);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
match self.pending {
|
||||
Some((p, t0)) if p.same(&c) && now.is_finite() && t0.is_finite() => {
|
||||
if now - t0 >= DWELL {
|
||||
self.pending = None;
|
||||
self.toggle(c);
|
||||
self.toggled = Some(c);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pending = Some((c, now));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths the acquired points (and the base point) send.
|
||||
pub fn paths(&self, cfg: &TrackConfig, base: Option<Vec2>) -> Vec<Path> {
|
||||
let mut out = Vec::new();
|
||||
let ray = |origin: Vec2, a: f64| PathShape::Ray { origin, dir: Vec2::from_angle(a) };
|
||||
if let Some(b) = base.filter(|b| b.is_finite()) {
|
||||
out.extend(cfg.polar_angles.iter().map(|a| Path { shape: ray(b, *a), kind: PathKind::Polar, from: b, name: "Polar" }));
|
||||
}
|
||||
for acq in &self.acquired {
|
||||
match *acq {
|
||||
Acquired::Point { at, name, ext } => {
|
||||
if cfg.otrack {
|
||||
out.extend(cfg.track_angles.iter().map(|a| Path { shape: ray(at, *a), kind: PathKind::Alignment, from: at, name }));
|
||||
}
|
||||
if let Some(shape) = ext.filter(|_| cfg.running(mode::EXT)) {
|
||||
out.push(Path { shape, kind: PathKind::Extension, from: at, name: "Extension" });
|
||||
}
|
||||
}
|
||||
Acquired::Parallel { dir, .. } => {
|
||||
if let Some(b) = base.filter(|b| b.is_finite())
|
||||
&& cfg.running(mode::PAR)
|
||||
{
|
||||
for d in [dir, -dir] {
|
||||
out.push(Path { shape: PathShape::Ray { origin: b, dir: d }, kind: PathKind::Parallel, from: b, name: "Parallel" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Acquired::Apparent { at, shape } => {
|
||||
if cfg.running(mode::APP) {
|
||||
out.push(Path { shape, kind: PathKind::Apparent, from: at, name: "Apparent Intersection" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.retain(|p| p.shape.is_finite() && p.from.is_finite());
|
||||
out
|
||||
}
|
||||
|
||||
/// The tracking point near `cursor`: the crossing of two paths, then a path crossing an
|
||||
/// object of the drawing, then the nearest point of one path. `tol` is how close (world
|
||||
/// units) the cursor must come to a path to slide on it, `aperture` how close a crossing
|
||||
/// must be. Stores the result in [`Tracker::last`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn track(
|
||||
&mut self,
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
cursor: Vec2,
|
||||
aperture: f64,
|
||||
tol: f64,
|
||||
cfg: &TrackConfig,
|
||||
base: Option<Vec2>,
|
||||
) -> Option<Track> {
|
||||
let t = self.find(d, space, cursor, aperture, tol, cfg, base);
|
||||
self.last = t.clone();
|
||||
t
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn find(&self, d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, tol: f64, cfg: &TrackConfig, base: Option<Vec2>) -> Option<Track> {
|
||||
if !(cursor.is_finite() && aperture.is_finite() && tol.is_finite() && aperture > 0.0 && tol > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let reach = aperture.max(tol);
|
||||
let near: Vec<(Path, Vec2, f64)> = self
|
||||
.paths(cfg, base)
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let f = p.shape.foot(cursor)?;
|
||||
let dd = f.dist(cursor);
|
||||
// Not right at the point the path comes from: every path passes there.
|
||||
(dd <= reach && f.dist(p.from) > tol).then_some((p, f, dd))
|
||||
})
|
||||
.take(MAX_NEAR_PATHS)
|
||||
.collect();
|
||||
if near.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// 1. Two paths crossing.
|
||||
let mut best: Option<(f64, Track)> = None;
|
||||
let offer = |dd: f64, t: Track, best: &mut Option<(f64, Track)>| {
|
||||
if t.point.is_finite() && best.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
*best = Some((dd, t));
|
||||
}
|
||||
};
|
||||
for (i, (a, _, _)) in near.iter().enumerate() {
|
||||
for (b, _, _) in near.iter().skip(i + 1) {
|
||||
if a.from.dist(b.from) <= cadcraft_geom::EPS {
|
||||
continue;
|
||||
}
|
||||
for x in intersect_ext(&a.shape.segment(), &b.shape.segment(), true) {
|
||||
let dd = x.dist(cursor);
|
||||
if dd <= aperture && a.shape.contains(x) && b.shape.contains(x) {
|
||||
let object = (a.kind == PathKind::Apparent && b.kind == PathKind::Apparent).then_some("Apparent Intersection");
|
||||
offer(dd, Track { point: x, paths: vec![*a, *b], object }, &mut best);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, t)) = best {
|
||||
return Some(t);
|
||||
}
|
||||
// 2. A path crossing an object.
|
||||
if cfg.running(mode::INT | mode::APP) {
|
||||
let ix = crate::spatial::index(d, space);
|
||||
let prims = super::gather(d, space, &ix, cursor, aperture, mode::NEA).map(|(_, p)| p).unwrap_or_default();
|
||||
for prim in prims.iter().take(200) {
|
||||
let extend = cfg.running(mode::APP) && super::extendable(prim);
|
||||
for seg in super::prim_segments(prim) {
|
||||
let seg = super::fix_circle(&seg);
|
||||
for (p, _, _) in &near {
|
||||
for x in intersect_ext(&p.shape.segment(), &seg, true) {
|
||||
let on_object = seg.closest(x).dist(x) <= 1e-9 * (1.0 + x.len());
|
||||
// Off the object, or along an object's extension: apparent.
|
||||
let apparent = !on_object || p.kind == PathKind::Apparent;
|
||||
let dd = x.dist(cursor);
|
||||
if dd > aperture
|
||||
|| !p.shape.contains(x)
|
||||
|| x.dist(p.from) <= tol
|
||||
|| !(on_object || extend)
|
||||
|| !cfg.running(if apparent { mode::APP } else { mode::INT })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let object = if apparent { "Apparent Intersection" } else { "Intersection" };
|
||||
offer(dd, Track { point: x, paths: vec![*p], object: Some(object) }, &mut best);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, t)) = best {
|
||||
return Some(t);
|
||||
}
|
||||
// 3. Along the nearest path.
|
||||
near.iter().filter(|(p, _, dd)| p.slides() && *dd <= tol).min_by(|a, b| a.2.total_cmp(&b.2)).map(|(p, f, _)| Track {
|
||||
point: *f,
|
||||
paths: vec![*p],
|
||||
object: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What resting the cursor at `cursor` would acquire: the object snap `hit` there (with its
|
||||
/// extension when it is a line or arc endpoint and EXT runs), else with EXT a line or arc
|
||||
/// endpoint, else with PAR a line (there is a base point), else with APP a line, arc or circle.
|
||||
pub fn candidate(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
cursor: Vec2,
|
||||
hit: Option<&SnapHit>,
|
||||
aperture: f64,
|
||||
cfg: &TrackConfig,
|
||||
base: Option<Vec2>,
|
||||
) -> Option<Acquired> {
|
||||
let nothing_to_acquire = !cfg.otrack && !cfg.running(mode::EXT | mode::PAR | mode::APP);
|
||||
if nothing_to_acquire || !(cursor.is_finite() && aperture.is_finite() && aperture > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let ix = crate::spatial::index(d, space);
|
||||
let prims = super::gather(d, space, &ix, cursor, aperture, mode::NEA).map(|(_, p)| p).unwrap_or_default();
|
||||
let hit = hit.filter(|h| h.deferred.is_none() && h.mode != mode::NEA && h.point.is_finite());
|
||||
let ext_end = if cfg.running(mode::EXT) { extension_near(&prims, hit.map_or(cursor, |h| h.point), aperture) } else { None };
|
||||
if let Some((at, ext)) = ext_end
|
||||
&& hit.is_none_or(|h| h.point.dist(at) <= cadcraft_geom::EPS * (1.0 + at.len()))
|
||||
{
|
||||
return Some(Acquired::Point { at, name: hit.map_or("Endpoint", |h| h.name), ext: Some(ext) });
|
||||
}
|
||||
if let Some(h) = hit {
|
||||
return cfg.otrack.then_some(Acquired::Point { at: h.point, name: h.name, ext: None });
|
||||
}
|
||||
let (prim, at) = prims
|
||||
.iter()
|
||||
.filter(|p| super::extendable(p))
|
||||
.filter_map(|p| {
|
||||
let at = super::prim_segments(p)
|
||||
.iter()
|
||||
.map(|s| super::fix_circle(s).closest(cursor))
|
||||
.min_by(|a, b| a.dist(cursor).total_cmp(&b.dist(cursor)))?;
|
||||
(at.is_finite() && at.dist(cursor) <= aperture).then_some((p, at))
|
||||
})
|
||||
.min_by(|a, b| a.1.dist(cursor).total_cmp(&b.1.dist(cursor)))?;
|
||||
let line = match prim {
|
||||
Prim::Seg(Segment::Line(l)) => Some((l.b - l.a).normalized()),
|
||||
Prim::Infinite { dir, .. } => Some(dir.normalized()),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|d| *d != Vec2::ZERO && d.is_finite());
|
||||
if cfg.running(mode::PAR)
|
||||
&& base.is_some()
|
||||
&& let Some(dir) = line
|
||||
{
|
||||
return Some(Acquired::Parallel { at, dir });
|
||||
}
|
||||
if cfg.running(mode::APP) {
|
||||
let shape = match prim {
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => PathShape::Circle { center: arc.center, radius: arc.radius, gap: None },
|
||||
Prim::Circle(c) => PathShape::Circle { center: c.center, radius: c.radius, gap: None },
|
||||
_ => PathShape::Line { origin: at, dir: line? },
|
||||
};
|
||||
return shape.is_finite().then_some(Acquired::Apparent { at, shape });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The line or arc endpoint nearest `p` within `aperture` and the extension path from it.
|
||||
fn extension_near(prims: &[Prim], p: Vec2, aperture: f64) -> Option<(Vec2, PathShape)> {
|
||||
let mut best: Option<(f64, Vec2, PathShape)> = None;
|
||||
for prim in prims {
|
||||
let Prim::Seg(s) = prim else { continue };
|
||||
for (end, other) in [(s.start(), s.end()), (s.end(), s.start())] {
|
||||
let dd = end.dist(p);
|
||||
if !dd.is_finite() || dd > aperture || best.as_ref().is_some_and(|(bd, ..)| *bd <= dd) {
|
||||
continue;
|
||||
}
|
||||
let shape = match s {
|
||||
Segment::Line(_) => PathShape::Ray { origin: end, dir: (end - other).normalized() },
|
||||
Segment::Arc { arc, .. } => PathShape::Circle { center: arc.center, radius: arc.radius, gap: Some((arc.start, arc.end)) },
|
||||
};
|
||||
if shape.is_finite() {
|
||||
best = Some((dd, end, shape));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, at, shape)| (at, shape))
|
||||
}
|
||||
|
||||
/// What one cursor update gives: the point to use and why.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CursorSnap {
|
||||
pub point: Vec2,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap: Option<SnapHit>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub track: Option<Track>,
|
||||
/// An acquisition is pending: update again after [`DWELL`] even if the cursor rests.
|
||||
pub wait: bool,
|
||||
}
|
||||
|
||||
/// One cursor update's inputs besides the cursor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CursorQuery {
|
||||
/// The base point (rubber-band origin), if the prompt has one.
|
||||
pub base: Option<Vec2>,
|
||||
/// The prompt resolves deferred tangent/perpendicular snaps.
|
||||
pub deferred: bool,
|
||||
/// The object snap aperture in world units (APERTURE pixels at the view scale).
|
||||
pub aperture: f64,
|
||||
/// How close, in world units, the cursor slides onto a tracking path.
|
||||
pub tol: f64,
|
||||
/// Seconds on any steady clock (acquisition timing).
|
||||
pub now: f64,
|
||||
/// Shift is held.
|
||||
pub shift: bool,
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// The cursor at a point prompt, snapped: running object snaps first (resting on a snap point
|
||||
/// acquires it for tracking), then grid snap, then tracking paths (polar, object snap
|
||||
/// tracking, Extension, Parallel, Apparent Intersection), then ortho.
|
||||
pub fn snap_cursor(&mut self, raw: Vec2, q: &CursorQuery) -> CursorSnap {
|
||||
let s = self.settings.clone();
|
||||
let angbase = self.angle_settings().angbase;
|
||||
let Ok(st) = self.state() else { return CursorSnap { point: raw, ..Default::default() } };
|
||||
let (doc, space) = (st.doc.clone(), st.edit_space());
|
||||
if !raw.is_finite() {
|
||||
return CursorSnap { point: raw, ..Default::default() };
|
||||
}
|
||||
let cfg = TrackConfig::new(&s, angbase);
|
||||
let osmode = s.osmode | if s.osnaphatch { mode::HATCH } else { 0 };
|
||||
let hit = super::osnap(&doc, &space, raw, q.aperture, osmode, q.base, q.deferred);
|
||||
let cand = candidate(&doc, &space, raw, hit.as_ref(), q.aperture, &cfg, q.base);
|
||||
let wait = self.tracking.hover(cand, q.now, cfg.shift_acquire, q.shift);
|
||||
if let Some(h) = hit {
|
||||
self.tracking.last = None;
|
||||
return CursorSnap { point: h.point, snap: Some(h), track: None, wait };
|
||||
}
|
||||
let mut p = raw;
|
||||
if s.snapmode {
|
||||
let (origin, angle) = super::grid_frame(&doc);
|
||||
p = super::grid_snap(p, s.snapunit, origin, angle);
|
||||
}
|
||||
if let Some(t) = self.tracking.track(&doc, &space, p, q.aperture, q.tol, &cfg, q.base) {
|
||||
return CursorSnap { point: t.point, snap: None, track: Some(t), wait };
|
||||
}
|
||||
if let Some(b) = q.base
|
||||
&& s.orthomode
|
||||
{
|
||||
p = super::ortho(b, p);
|
||||
}
|
||||
CursorSnap { point: p, snap: None, track: None, wait }
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,20 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
const SESSION_VARS: &[&str] = &[
|
||||
"OSMODE",
|
||||
"OSNAPHATCH",
|
||||
"ORTHOMODE",
|
||||
"POLARMODE",
|
||||
"POLARANG",
|
||||
"POLARADDANG",
|
||||
"AUTOSNAP",
|
||||
"GRIDMODE",
|
||||
"SNAPMODE",
|
||||
"SNAPUNIT",
|
||||
"GRIDUNIT",
|
||||
"GRIDMAJOR",
|
||||
"DYNMODE",
|
||||
"DYNPIFORMAT",
|
||||
"DYNPICOORDS",
|
||||
"LWDISPLAY",
|
||||
"QPMODE",
|
||||
"PICKBOX",
|
||||
@@ -23,7 +28,10 @@ const SESSION_VARS: &[&str] = &[
|
||||
"PICKADD",
|
||||
"GRIPSIZE",
|
||||
"CURSORSIZE",
|
||||
"MAXARRAY",
|
||||
"LASTPOINT",
|
||||
"FONTALT",
|
||||
"FONTFALLBACK",
|
||||
];
|
||||
|
||||
pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
@@ -31,8 +39,12 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
let st = &s.settings;
|
||||
let v = match n.as_str() {
|
||||
"OSMODE" => json!(st.osmode),
|
||||
"OSNAPHATCH" => json!(i32::from(st.osnaphatch)),
|
||||
"ORTHOMODE" => json!(i32::from(st.orthomode)),
|
||||
"POLARMODE" => json!(i32::from(st.polarmode)),
|
||||
"POLARMODE" => json!(st.polar_flags),
|
||||
"POLARADDANG" => json!(st.polaraddang.iter().map(|a| format!("{}", (a.to_degrees() * 1e6).round() / 1e6)).collect::<Vec<_>>().join(";")),
|
||||
// Marker, tooltip and tracking tooltip always on (1 + 4 + 32); polar 8, tracking 16.
|
||||
"AUTOSNAP" => json!(37 + 8 * u32::from(st.polarmode) + 16 * u32::from(st.otrack)),
|
||||
"POLARANG" => json!(st.polarang.to_degrees()),
|
||||
"GRIDMODE" => json!(i32::from(st.gridmode)),
|
||||
"SNAPMODE" => json!(i32::from(st.snapmode)),
|
||||
@@ -40,6 +52,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"GRIDUNIT" => json!([st.gridunit.x, st.gridunit.y]),
|
||||
"GRIDMAJOR" => json!(st.gridmajor),
|
||||
"DYNMODE" => json!(i32::from(st.dynmode)),
|
||||
"DYNPIFORMAT" => json!(i32::from(st.dynpi_cartesian)),
|
||||
"DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)),
|
||||
"LWDISPLAY" => json!(i32::from(st.lwdisplay)),
|
||||
"QPMODE" => json!(i32::from(st.qpmode)),
|
||||
"PICKBOX" => json!(st.pickbox),
|
||||
@@ -48,7 +62,13 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"PICKADD" => json!(i32::from(st.pickadd)),
|
||||
"GRIPSIZE" => json!(st.gripsize),
|
||||
"CURSORSIZE" => json!(st.cursorsize),
|
||||
"MAXARRAY" => json!(st.maxarray),
|
||||
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
|
||||
// Held in radians (as `$ANGBASE` in DXF); reported in degrees, like POLARANG.
|
||||
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
|
||||
// Process-wide font substitution (profile settings, not saved in the drawing).
|
||||
"FONTALT" => json!(cadcraft_fonts::ttf::font_alt()),
|
||||
"FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()),
|
||||
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
|
||||
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
|
||||
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
|
||||
@@ -72,8 +92,16 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
json!([p.x, p.y, 0.0])
|
||||
}
|
||||
_ => {
|
||||
let h = s.doc().ok()?.header.get(&n)?;
|
||||
serde_json::to_value(h).ok()?
|
||||
let d = s.doc().ok()?;
|
||||
match d.header.get(&n) {
|
||||
Some(h) => serde_json::to_value(h).ok()?,
|
||||
// A dimension variable the header doesn't carry reads from the current style.
|
||||
None => {
|
||||
let field = cadcraft_doc::DIMVARS.iter().find(|(v, _)| *v == n)?.1;
|
||||
let st = d.dim_style(&d.header.str("DIMSTYLE", "Standard"))?;
|
||||
serde_json::to_value(st).ok()?.get(field)?.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(v)
|
||||
@@ -93,14 +121,37 @@ fn as_pt(v: &Value) -> Option<cadcraft_geom::Vec2> {
|
||||
crate::cmd::point_value(v).or_else(|| as_f64(v).map(|f| cadcraft_geom::Vec2::new(f, f)))
|
||||
}
|
||||
|
||||
/// Variables `get` reports from session or drawing state that `set` can't change.
|
||||
const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX"];
|
||||
|
||||
pub fn is_read_only(name: &str) -> bool {
|
||||
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
let n = name.trim().to_ascii_uppercase();
|
||||
if is_read_only(&n) {
|
||||
return Err(EngineError::BadParams { cmd: "setvar".into(), msg: format!("{n} is read-only") });
|
||||
}
|
||||
let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") };
|
||||
let st = &mut s.settings;
|
||||
match n.as_str() {
|
||||
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
|
||||
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
|
||||
"ORTHOMODE" => st.orthomode = as_bool(v).ok_or_else(bad)?,
|
||||
"POLARMODE" => st.polarmode = as_bool(v).ok_or_else(bad)?,
|
||||
"POLARMODE" => st.polar_flags = as_i(v).filter(|b| (0..=15).contains(b)).ok_or_else(bad)? as u32,
|
||||
"POLARADDANG" => {
|
||||
let text = v.as_str().map(str::to_string).or_else(|| as_f64(v).map(|f| f.to_string())).ok_or_else(bad)?;
|
||||
let angles: Option<Vec<f64>> =
|
||||
text.split(';').map(str::trim).filter(|t| !t.is_empty()).map(|t| t.parse::<f64>().ok().filter(|a| a.is_finite())).collect();
|
||||
let angles = angles.filter(|a| a.len() <= 10).ok_or_else(bad)?;
|
||||
st.polaraddang = angles.into_iter().map(f64::to_radians).collect();
|
||||
}
|
||||
"AUTOSNAP" => {
|
||||
let bits = as_i(v).filter(|b| (0..=63).contains(b)).ok_or_else(bad)?;
|
||||
st.polarmode = bits & 8 != 0;
|
||||
st.otrack = bits & 16 != 0;
|
||||
}
|
||||
"POLARANG" => st.polarang = as_f64(v).filter(|a| *a > 0.0).ok_or_else(bad)?.to_radians(),
|
||||
"GRIDMODE" => st.gridmode = as_bool(v).ok_or_else(bad)?,
|
||||
"SNAPMODE" => st.snapmode = as_bool(v).ok_or_else(bad)?,
|
||||
@@ -108,6 +159,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"GRIDUNIT" => st.gridunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?,
|
||||
"GRIDMAJOR" => st.gridmajor = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0) as u32,
|
||||
"DYNMODE" => st.dynmode = as_bool(v).ok_or_else(bad)?,
|
||||
"DYNPIFORMAT" => st.dynpi_cartesian = as_bool(v).ok_or_else(bad)?,
|
||||
"DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?,
|
||||
"LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?,
|
||||
"QPMODE" => st.qpmode = as_i(v).ok_or_else(bad)? > 0,
|
||||
"PICKBOX" => st.pickbox = as_f64(v).ok_or_else(bad)?.clamp(0.0, 50.0),
|
||||
@@ -116,6 +169,18 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,
|
||||
"GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0),
|
||||
"CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0),
|
||||
"MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,
|
||||
"FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?),
|
||||
"FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?),
|
||||
// A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line).
|
||||
"ANGBASE" => {
|
||||
let a = match v {
|
||||
Value::String(t) => s.angle_settings().amount(t),
|
||||
_ => as_f64(v).map(f64::to_radians),
|
||||
};
|
||||
let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?;
|
||||
s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a));
|
||||
}
|
||||
_ => {
|
||||
let d = s.doc_mut()?;
|
||||
let val = match v {
|
||||
@@ -133,12 +198,26 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
// Keep the header type stable for known numeric vars.
|
||||
if let Some(old) = d.header.get(&n)
|
||||
&& matches!(old, cadcraft_doc::HVal::Real(_))
|
||||
&& let Some(f) = val.as_f64()
|
||||
{
|
||||
d.header.set_f64(&n, f);
|
||||
return Ok(());
|
||||
match d.header.get(&n) {
|
||||
Some(cadcraft_doc::HVal::Real(_)) => {
|
||||
d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?);
|
||||
return Ok(());
|
||||
}
|
||||
Some(cadcraft_doc::HVal::Int(_)) => {
|
||||
let i = match val {
|
||||
cadcraft_doc::HVal::Int(i) => i,
|
||||
cadcraft_doc::HVal::Real(f) if f.fract() == 0.0 && f.abs() < 1e15 => f as i64,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
d.header.set_i64(&n, i);
|
||||
return Ok(());
|
||||
}
|
||||
// A point variable takes `x,y` typed at the command line too.
|
||||
Some(cadcraft_doc::HVal::Point(_)) => {
|
||||
d.header.set(&n, cadcraft_doc::HVal::Point(as_pt(v).ok_or_else(bad)?.to3(0.0)));
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d.header.set(&n, val);
|
||||
}
|
||||
@@ -155,7 +234,9 @@ pub fn list(s: &Session) -> Value {
|
||||
}
|
||||
if let Ok(d) = s.doc() {
|
||||
for (k, v) in &d.header.vars {
|
||||
if !k.starts_with("CADCRAFT_") {
|
||||
if k == "ANGBASE" {
|
||||
m.insert(k.clone(), get(s, k).unwrap_or(Value::Null));
|
||||
} else if !k.starts_with("CADCRAFT_") {
|
||||
m.insert(k.clone(), serde_json::to_value(v).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
|
||||
+221
-2
@@ -25,6 +25,26 @@ fn line_via_command_line() {
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_while_a_command_runs_redraw() {
|
||||
// The canvas rebuilds when (revision, doc pointer) changes; each new segment must change it (issue #4).
|
||||
fn key(s: &Session) -> (u64, usize) {
|
||||
let st = s.state().unwrap();
|
||||
(st.revision, Arc::as_ptr(&st.doc) as usize)
|
||||
}
|
||||
let mut s = Session::new();
|
||||
for (cmd, pts) in [("line", ["0,0", "10,0", "10,5", "0,5"]), ("pline", ["20,0", "30,0", "30,5", "20,5"])] {
|
||||
s.cmdline(cmd).unwrap();
|
||||
s.cmdline(pts[0]).unwrap();
|
||||
for p in &pts[1..] {
|
||||
let before = key(&s);
|
||||
s.cmdline(p).unwrap();
|
||||
assert_ne!(key(&s), before, "{cmd}: segment to {p} not redrawn");
|
||||
}
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_undo_option_and_aliases() {
|
||||
let mut s = Session::new();
|
||||
@@ -322,6 +342,45 @@ fn copy_rotate_scale_mirror() {
|
||||
assert_eq!(s.doc().unwrap().model.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_reference_typed_and_picked() {
|
||||
// Issue #17: SCALE ▸ Reference scales by new length / reference length.
|
||||
fn end(s: &Session) -> Vec2 {
|
||||
let e = s.doc().unwrap().model.iter().next().unwrap();
|
||||
let EntityKind::Line(l) = &e.kind else { panic!("expected a line") };
|
||||
l.b.xy()
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [56, 0]]})).unwrap();
|
||||
// Typed lengths: 56 → 100.
|
||||
for t in ["scale", "all", "", "0,0", "r"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify reference length");
|
||||
s.cmdline("56").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify new length");
|
||||
s.cmdline("100").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(end(&s).near(Vec2::new(100.0, 0.0), 1e-9));
|
||||
// Reference from two points, a zero new length is rejected, new length picked from the base point.
|
||||
for t in ["scale", "all", "", "0,0", "r", "0,0", "100,0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
s.cmdline("0").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
s.cmdline("0,25").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(end(&s).near(Vec2::new(25.0, 0.0), 1e-9));
|
||||
// A zero reference length is rejected and asked again.
|
||||
for t in ["scale", "all", "", "0,0", "r", "0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify reference length");
|
||||
s.cmdline("25").unwrap();
|
||||
s.cmdline("50").unwrap();
|
||||
assert!(end(&s).near(Vec2::new(50.0, 0.0), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_properties() {
|
||||
let mut s = Session::new();
|
||||
@@ -390,6 +449,69 @@ fn sysvars_roundtrip() {
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 2.5);
|
||||
assert!(s.execute("setvar", &json!({"name": "ORTHOMODE", "value": "x"})).is_err());
|
||||
assert_eq!(sysvars::get(&s, "orthomode"), Some(json!(0)));
|
||||
// FONTALT is a process-wide font setting, not a drawing header variable.
|
||||
s.execute("setvar", &json!({"name": "FONTALT", "value": "no-such-font-130"})).unwrap();
|
||||
assert_eq!(sysvars::get(&s, "fontalt"), Some(json!("no-such-font-130")));
|
||||
assert!(s.doc().unwrap().header.get("FONTALT").is_none());
|
||||
assert!(s.execute("setvar", &json!({"name": "FONTALT", "value": 3})).is_err());
|
||||
s.execute("setvar", &json!({"name": "FONTALT", "value": "."})).unwrap();
|
||||
assert_eq!(sysvars::get(&s, "FONTALT"), Some(json!("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_at_command_line() {
|
||||
let mut s = Session::new();
|
||||
// One line, as typed in issue #30: Space separates the inputs.
|
||||
s.cmdline("SETVAR LUNITS 4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.doc().unwrap().header.i64("LUNITS", 0), 4);
|
||||
// Step by step, offering the current value as the default.
|
||||
s.cmdline("setvar").unwrap();
|
||||
s.cmdline("luprec").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().default.as_deref(), Some("4"));
|
||||
s.cmdline("2").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.doc().unwrap().header.i64("LUPREC", 0), 2);
|
||||
// Enter keeps the value.
|
||||
s.cmdline("set osmode").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
// A value of the wrong type re-prompts and changes nothing.
|
||||
s.cmdline("SETVAR LUNITS abc").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
assert_eq!(s.doc().unwrap().header.i64("LUNITS", 0), 4);
|
||||
s.cancel();
|
||||
// Unknown and read-only names end the command without creating or changing anything.
|
||||
s.cmdline("SETVAR NOSUCHVAR 1").unwrap();
|
||||
assert!(sysvars::get(&s, "NOSUCHVAR").is_none());
|
||||
s.cmdline("SETVAR DWGNAME").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.execute("setvar", &json!({"name": "dbmod", "value": 0})).is_err());
|
||||
// `?` lists the variables.
|
||||
s.cmdline("SETVAR ?").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_keeps_header_types() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("setvar", &json!({"name": "LUNITS", "value": "x"})).is_err());
|
||||
assert!(s.execute("setvar", &json!({"name": "LUNITS", "value": 2.5})).is_err());
|
||||
s.execute("setvar", &json!({"name": "LUNITS", "value": 3.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.get("LUNITS"), Some(&cadcraft_doc::HVal::Int(3)));
|
||||
assert!(s.execute("setvar", &json!({"name": "LTSCALE", "value": "big"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_input_pointer_settings() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(sysvars::get(&s, "DYNPIFORMAT"), Some(json!(0)));
|
||||
assert_eq!(sysvars::get(&s, "dynpicoords"), Some(json!(0)));
|
||||
s.execute("setvar", &json!({"name": "dynpiformat", "value": 1})).unwrap();
|
||||
s.execute("setvar", &json!({"name": "DYNPICOORDS", "value": 1})).unwrap();
|
||||
assert!(s.settings.dynpi_cartesian && s.settings.dynpi_absolute);
|
||||
assert!(s.execute("setvar", &json!({"name": "DYNPIFORMAT", "value": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -401,6 +523,13 @@ fn script_runs_commands() {
|
||||
assert!(matches!(&t.kind, EntityKind::Text(t) if t.value == "Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_splits_line_at_keyword_prompt() {
|
||||
let mut s = Session::new();
|
||||
s.script("POLYGON 6 200,20 I 15").unwrap();
|
||||
assert_eq!(kinds(&s), vec!["Polyline"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_repeats_last_command() {
|
||||
let mut s = Session::new();
|
||||
@@ -442,6 +571,11 @@ fn hostile_params_never_panic() {
|
||||
fn every_interactive_command_starts_and_cancels() {
|
||||
for c in command_specs().iter().filter(|c| c.interactive.is_some()) {
|
||||
let mut s = Session::new();
|
||||
if (c.enabled)(&s).is_err() {
|
||||
// Unavailable in a new drawing (e.g. pasting with an empty clipboard): refused.
|
||||
assert!(s.start(c.id).is_err() && s.running.is_none(), "{}", c.id);
|
||||
continue;
|
||||
}
|
||||
s.start(c.id).unwrap();
|
||||
let _ = s.prompt_text();
|
||||
let _ = s.preview(Vec2::new(1.0, 1.0));
|
||||
@@ -468,7 +602,7 @@ fn count_from_the_menu_shows_its_result() {
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
s.cmdline("block Valve 0,0").unwrap();
|
||||
s.cmdline("insert Valve 10,0 1 0").unwrap();
|
||||
s.cmdline("insert Valve 10,0 1 1 0").unwrap();
|
||||
s.start("count").unwrap();
|
||||
let n = s.log.len();
|
||||
assert_eq!(&s.log[n - 3..], ["Block references in model space:", " Valve: 2", " Total: 2"]);
|
||||
@@ -685,7 +819,7 @@ fn block_insert_with_attributes() {
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.doc().unwrap().block("Valve").is_some());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "originals converted to one insert");
|
||||
s.cmdline("insert Valve 10,0 2 90 V-101").unwrap();
|
||||
s.cmdline("insert Valve 10,0 2 2 90 V-101").unwrap();
|
||||
let ins: Vec<_> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect();
|
||||
assert_eq!(ins.len(), 2);
|
||||
@@ -867,3 +1001,88 @@ fn arc_refuses_zero_radius() {
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The single polyline in a fresh drawing after typing `script`, then pressing Enter.
|
||||
fn typed_pline(script: &str) -> cadcraft_doc::LwPolyline {
|
||||
let mut s = Session::new();
|
||||
for line in script.split('\n') {
|
||||
s.cmdline(line).unwrap();
|
||||
}
|
||||
s.cmdline("").unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut it = d.model.iter();
|
||||
let pl = match &it.next().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => p.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(it.next().is_none(), "one polyline");
|
||||
pl
|
||||
}
|
||||
|
||||
/// (const_width, [(start_width, end_width) per vertex]).
|
||||
fn widths(p: &cadcraft_doc::LwPolyline) -> (f64, Vec<(f64, f64)>) {
|
||||
(p.const_width, p.vertices.iter().map(|v| (v.start_width, v.end_width)).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pline_halfwidth_is_centre_to_edge() {
|
||||
// #83: half-width 0.5 at both prompts is a band 1.0 wide; Width keeps the total width.
|
||||
assert_eq!(typed_pline("pline 0,0 h 0.5 0.5 10,0").const_width, 1.0);
|
||||
assert_eq!(typed_pline("pline 0,0 w 0.5 0.5 10,0").const_width, 0.5);
|
||||
assert_eq!(typed_pline("pline 0,0 w 1 1 10,0").const_width, 1.0);
|
||||
// The ending half-width defaults to the starting one.
|
||||
assert_eq!(typed_pline("pline 0,0 h 0.5\n\n10,0").const_width, 1.0);
|
||||
// A half-width taper.
|
||||
assert_eq!(widths(&typed_pline("pline 0,0 h 0.5 0.1 10,0")), (0.0, vec![(1.0, 0.2), (0.0, 0.0)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pline_width_answers_taper_the_next_segment() {
|
||||
// #104: the starting and ending widths are the segment's own, not one constant width.
|
||||
assert_eq!(widths(&typed_pline("pline 0,0 w 1 0.2 10,0")), (0.0, vec![(1.0, 0.2), (0.0, 0.0)]));
|
||||
assert_eq!(widths(&typed_pline("pline 0,0 w 0.2 1 10,0")), (0.0, vec![(0.2, 1.0), (0.0, 0.0)]));
|
||||
// Equal answers, or Enter at the ending prompt, stay one constant width.
|
||||
assert_eq!(widths(&typed_pline("pline 0,0 w 1 1 10,0")), (1.0, vec![(0.0, 0.0), (0.0, 0.0)]));
|
||||
assert_eq!(widths(&typed_pline("pline 0,0 w 1\n\n10,0")), (1.0, vec![(0.0, 0.0), (0.0, 0.0)]));
|
||||
// After a taper, later segments are uniform at the ending width.
|
||||
let p = typed_pline("pline 0,0 w 1 0.2 10,0 20,0 30,0");
|
||||
assert_eq!(widths(&p), (0.0, vec![(1.0, 0.2), (0.2, 0.2), (0.2, 0.2), (0.0, 0.0)]));
|
||||
// A width set mid-polyline applies from that segment on; the closing segment takes it too.
|
||||
let p = typed_pline("pline 0,0 10,0 w 2 2 10,10 c");
|
||||
assert!(p.closed);
|
||||
assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (2.0, 2.0), (2.0, 2.0)]));
|
||||
// Undo drops the taper with its segment.
|
||||
let p = typed_pline("pline 0,0 10,0 w 1 0.2 20,0 u");
|
||||
assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (0.0, 0.0)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_and_drag_selection_window() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("circle 50,50 1").unwrap();
|
||||
// No command: a drag from (-1,-1) to (11,1) is a window around the line only.
|
||||
assert!(s.begin_window(Vec2::new(-1.0, -1.0)));
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(s.selection().len(), 1);
|
||||
assert!(s.pending_window.is_none());
|
||||
// Dragging right to left is a crossing window: it also catches the circle it touches.
|
||||
s.set_selection(Vec::new());
|
||||
assert!(s.begin_window(Vec2::new(60.0, 60.0)));
|
||||
s.idle_click(Vec2::new(50.0, 40.0), false).unwrap();
|
||||
assert_eq!(s.selection().len(), 1);
|
||||
// A command asking for objects: the window feeds its selection.
|
||||
s.set_selection(Vec::new());
|
||||
s.cmdline("erase").unwrap();
|
||||
assert!(s.begin_window(Vec2::new(-1.0, -1.0)));
|
||||
s.input(Input::Point(Vec2::new(11.0, 1.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(kinds(&s), vec!["Circle"]);
|
||||
// A drawing command never opens one, and neither does a hostile point.
|
||||
s.cmdline("line").unwrap();
|
||||
assert!(!s.begin_window(Vec2::ZERO));
|
||||
s.cancel();
|
||||
assert!(!s.begin_window(Vec2::new(f64::NAN, 0.0)));
|
||||
assert!(s.pending_window.is_none());
|
||||
}
|
||||
+104
-27
@@ -137,42 +137,111 @@ fn parse_mixed(s: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an angle typed by the user (degrees by default): `45`, `45d30'`, `0.5r`, `100g`, `N45dE`.
|
||||
/// How typed angles are read: the drawing's angle settings (UNITS). Bare numbers are in AUNITS,
|
||||
/// and directions are measured from ANGBASE, turning as ANGDIR says.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AngleSettings {
|
||||
/// AUNITS: 0 decimal degrees, 1 degrees/minutes/seconds, 2 grads, 3 radians, 4 surveyor's.
|
||||
pub aunits: i64,
|
||||
/// ANGBASE: the direction of angle 0, in radians counterclockwise from +X (as `$ANGBASE` in DXF).
|
||||
pub angbase: f64,
|
||||
/// ANGDIR 1: positive angles turn clockwise.
|
||||
pub clockwise: bool,
|
||||
}
|
||||
|
||||
impl AngleSettings {
|
||||
pub fn from_header(h: &cadcraft_doc::Header) -> Self {
|
||||
AngleSettings { aunits: h.i64("AUNITS", 0), angbase: h.f64("ANGBASE", 0.0), clockwise: h.i64("ANGDIR", 0) != 0 }
|
||||
}
|
||||
|
||||
/// A typed angle size in radians (included angles, sweeps, angles from an axis): a bare
|
||||
/// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`.
|
||||
pub fn amount(&self, s: &str) -> Option<f64> {
|
||||
parse_units(s, self.aunits).map(|(a, _)| a)
|
||||
}
|
||||
|
||||
/// A typed rotation in radians counterclockwise: the amount, turned clockwise when ANGDIR is 1.
|
||||
pub fn rotation(&self, s: &str) -> Option<f64> {
|
||||
self.amount(s).map(|a| if self.clockwise { -a } else { a })
|
||||
}
|
||||
|
||||
/// A typed direction in radians counterclockwise from +X: the amount measured from ANGBASE in
|
||||
/// the ANGDIR sense. Surveyor's bearings (`N45dE`) are absolute. The overrides measure from
|
||||
/// +X counterclockwise whatever ANGBASE and ANGDIR say: `<<a` in degrees, `<<<a` in AUNITS.
|
||||
pub fn direction(&self, s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
if let Some(r) = t.strip_prefix("<<<") {
|
||||
return parse_units(r, self.aunits).map(|(a, _)| a);
|
||||
}
|
||||
if let Some(r) = t.strip_prefix("<<") {
|
||||
return parse_units(r, 0).map(|(a, _)| a);
|
||||
}
|
||||
let (a, absolute) = parse_units(t, self.aunits)?;
|
||||
if absolute {
|
||||
return Some(a);
|
||||
}
|
||||
let v = self.angbase + if self.clockwise { -a } else { a };
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a direction typed with the default angle settings (degrees, 0 = +X, counterclockwise):
|
||||
/// `45`, `45d30'`, `0.5r`, `100g`, `N45dE`. Typed input reads the drawing's settings instead
|
||||
/// (`Session::angle_settings`).
|
||||
pub fn parse_angle(s: &str) -> Option<f64> {
|
||||
AngleSettings::default().direction(s)
|
||||
}
|
||||
|
||||
/// A typed angle in radians and whether it is absolute (a surveyor's bearing).
|
||||
fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(r) = t.strip_suffix('r') {
|
||||
return r.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
}
|
||||
if let Some(g) = t.strip_suffix('g') {
|
||||
return g.trim().parse::<f64>().ok().filter(|v| v.is_finite()).map(|v| (v * 0.9).to_radians());
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix('n').or_else(|| t.strip_prefix('s')) {
|
||||
let north = t.starts_with('n');
|
||||
let (ang_s, east) = if let Some(a) = rest.strip_suffix('e') {
|
||||
(a, true)
|
||||
} else {
|
||||
let a = rest.strip_suffix('w')?;
|
||||
(a, false)
|
||||
let num = |v: &str| v.trim().parse::<f64>().ok();
|
||||
let v = if let Some(r) = t.strip_suffix('r') {
|
||||
(num(r)?, false)
|
||||
} else if let Some(g) = t.strip_suffix('g') {
|
||||
((num(g)? * 0.9).to_radians(), false)
|
||||
} else if let Some(b) = bearing(&t) {
|
||||
(b, true)
|
||||
} else if let Some(v) = num(&t) {
|
||||
let a = match aunits {
|
||||
2 => (v * 0.9).to_radians(),
|
||||
3 => v,
|
||||
_ => v.to_radians(),
|
||||
};
|
||||
let a = parse_dms(ang_s.trim())?;
|
||||
let deg = match (north, east) {
|
||||
(true, true) => 90.0 - a,
|
||||
(true, false) => 90.0 + a,
|
||||
(false, false) => 270.0 - a,
|
||||
(false, true) => 270.0 + a,
|
||||
};
|
||||
return Some(deg.to_radians());
|
||||
}
|
||||
parse_dms(&t).map(f64::to_radians)
|
||||
(a, false)
|
||||
} else {
|
||||
(parse_dms(&t)?.to_radians(), false)
|
||||
};
|
||||
v.0.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X.
|
||||
fn bearing(t: &str) -> Option<f64> {
|
||||
let north = t.starts_with('n');
|
||||
let rest = t.strip_prefix('n').or_else(|| t.strip_prefix('s'))?;
|
||||
let (ang_s, east) = match rest.strip_suffix('e') {
|
||||
Some(a) => (a, true),
|
||||
None => (rest.strip_suffix('w')?, false),
|
||||
};
|
||||
let a = parse_dms(ang_s.trim())?;
|
||||
let deg = match (north, east) {
|
||||
(true, true) => 90.0 - a,
|
||||
(true, false) => 90.0 + a,
|
||||
(false, false) => 270.0 - a,
|
||||
(false, true) => 270.0 + a,
|
||||
};
|
||||
Some(deg.to_radians())
|
||||
}
|
||||
|
||||
/// Degrees, `45d`, `45d30'` or `45d30'15"` (a leading `-` negates the whole angle).
|
||||
fn parse_dms(t: &str) -> Option<f64> {
|
||||
if let Ok(v) = t.parse::<f64>() {
|
||||
return v.is_finite().then_some(v);
|
||||
}
|
||||
let (neg, t) = match t.trim().strip_prefix('-') {
|
||||
Some(r) => (true, r.trim()),
|
||||
None => (false, t.trim()),
|
||||
};
|
||||
let (d, rest) = t.split_once('d').unwrap_or((t, ""));
|
||||
let mut deg: f64 = d.trim().parse().ok()?;
|
||||
let rest = rest.trim();
|
||||
@@ -186,9 +255,17 @@ fn parse_dms(t: &str) -> Option<f64> {
|
||||
deg += s.parse::<f64>().ok()? / 3600.0;
|
||||
}
|
||||
}
|
||||
let deg = if neg { -deg } else { deg };
|
||||
deg.is_finite().then_some(deg)
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// How typed angles are read in the current drawing (the defaults without one).
|
||||
pub fn angle_settings(&self) -> AngleSettings {
|
||||
self.doc().map(|d| AngleSettings::from_header(&d.header)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEG: f64 = PI / 180.0;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Angle prompts read typed angles with the drawing's settings (#353): AUNITS for bare numbers,
|
||||
//! rotations turn clockwise when ANGDIR is 1, directions are measured from ANGBASE.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{DimKind, EntityKind};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
const QUARTER: &str = "1.5707963267948966";
|
||||
const EIGHTH: &str = "0.7853981633974483";
|
||||
|
||||
/// Radians, clockwise angles, 0 = north.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (name, value) in [("AUNITS", json!(3)), ("ANGDIR", json!(1)), ("ANGBASE", json!(90))] {
|
||||
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn last(s: &Session) -> EntityKind {
|
||||
s.doc().unwrap().model.last().unwrap().kind.clone()
|
||||
}
|
||||
|
||||
/// `a - b` turned into (-π, π].
|
||||
fn diff(a: f64, b: f64) -> f64 {
|
||||
let d = (a - b).rem_euclid(2.0 * PI);
|
||||
if d > PI { d - 2.0 * PI } else { d }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_angle_is_a_rotation_in_aunits() {
|
||||
let mut s = session();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s.script(&format!("ROTATE ALL\n\n0,0 {QUARTER}\n")).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
// A quarter turn clockwise (ANGDIR 1), not 1.57 degrees counterclockwise.
|
||||
match last(&s) {
|
||||
EntityKind::Line(l) => assert!(l.b.xy().near(Vec2::new(0.0, -10.0), 1e-9), "{:?}", l.b),
|
||||
k => panic!("expected a line, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_angle_is_a_rotation_in_aunits() {
|
||||
let mut s = session();
|
||||
s.script(&format!("HATCH A {QUARTER}\n\n")).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let a = s.doc().unwrap().header.f64("HPANG", 0.0);
|
||||
assert!(diff(a, -FRAC_PI_2).abs() < 1e-9, "HPANG {a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_line_and_text_angles_are_directions() {
|
||||
let mut s = session();
|
||||
// Rotated 0 points at ANGBASE (north); the text angle a quarter turn clockwise from north is east.
|
||||
s.script(&format!("DIMLINEAR 0,0 10,0 R 0 A {QUARTER} 5,5\n")).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
match last(&s) {
|
||||
EntityKind::Dimension(d) => {
|
||||
let DimKind::Linear { rotation } = d.kind else { panic!("expected a linear dimension, got {:?}", d.kind) };
|
||||
assert!(diff(rotation, FRAC_PI_2).abs() < 1e-9, "rotation {rotation}");
|
||||
assert!(diff(d.text_rotation, 0.0).abs() < 1e-9, "text rotation {}", d.text_rotation);
|
||||
}
|
||||
k => panic!("expected a dimension, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_and_mleader_angles_are_amounts_in_aunits() {
|
||||
let mut s = session();
|
||||
for l in ["0,0 12,0", "10,-2 10,12"] {
|
||||
s.cmdline(&format!("LINE {l}")).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
// Length 2 on the first line at an eighth turn from it, whatever ANGBASE and ANGDIR say.
|
||||
s.script(&format!("CHAMFER A 2 {EIGHTH} 5,0 10,5\n")).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let d = s.doc().unwrap().header.f64("CHAMFERD", 0.0);
|
||||
assert!((d - FRAC_PI_4).abs() < 1e-9, "CHAMFERD {d}");
|
||||
let chamfer = s.doc().unwrap().model.iter().any(|e| match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
(a.near(Vec2::new(8.0, 0.0), 1e-6) && b.near(Vec2::new(10.0, 2.0), 1e-6))
|
||||
|| (b.near(Vec2::new(8.0, 0.0), 1e-6) && a.near(Vec2::new(10.0, 2.0), 1e-6))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
assert!(chamfer, "{:?}", s.doc().unwrap().model.iter().map(|e| &e.kind).collect::<Vec<_>>());
|
||||
|
||||
// The first angle constraint snaps the leader to eighth-turn steps.
|
||||
s.script(&format!("MLEADER O F {EIGHTH} X 0,0 10,8 note\n")).unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::MLeader(m) => {
|
||||
let r = Vec2::new(10.0, 8.0).len();
|
||||
assert!(m.landing.xy().near(Vec2::new(r, r) / 2f64.sqrt(), 1e-6), "{:?}", m.landing);
|
||||
}
|
||||
k => panic!("expected a multileader, got {k:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Typed angles follow the drawing's angle settings: AUNITS for bare numbers, directions
|
||||
//! measured from ANGBASE in the ANGDIR sense, `<<` / `<<<` overrides.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::units::AngleSettings;
|
||||
use serde_json::json;
|
||||
|
||||
fn setvar(s: &mut Session, name: &str, value: serde_json::Value) {
|
||||
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
|
||||
}
|
||||
|
||||
/// Draws LINE 0,0 `to` at the command line; returns the end point.
|
||||
fn line_end(s: &mut Session, to: &str) -> Vec2 {
|
||||
for t in ["line", "0,0", to, ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::Line(l) => l.b.xy(),
|
||||
k => panic!("expected a line, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_near(p: Vec2, x: f64, y: f64, what: &str) {
|
||||
assert!(p.near(Vec2::new(x, y), 1e-9), "{what}: got {p:?}, expected ({x}, {y})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_angles_measure_from_angbase_in_angdir() {
|
||||
let mut s = Session::new();
|
||||
// 0 = north, clockwise (as in surveying).
|
||||
setvar(&mut s, "ANGBASE", json!(90));
|
||||
setvar(&mut s, "ANGDIR", json!(1));
|
||||
assert!((s.doc().unwrap().header.f64("ANGBASE", 0.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "ANGBASE is held in radians");
|
||||
assert_near(line_end(&mut s, "@10<90"), 10.0, 0.0, "@10<90");
|
||||
assert_near(line_end(&mut s, "@10<0"), 0.0, 10.0, "@10<0");
|
||||
assert_near(line_end(&mut s, "10<180"), 0.0, -10.0, "10<180");
|
||||
// The overrides measure from +X counterclockwise: `<<` in degrees, `<<<` in AUNITS.
|
||||
assert_near(line_end(&mut s, "@10<<90"), 0.0, 10.0, "@10<<90");
|
||||
setvar(&mut s, "AUNITS", json!(3));
|
||||
assert_near(line_end(&mut s, "@10<<<3.141592653589793"), -10.0, 0.0, "@10<<<pi");
|
||||
// A surveyor's bearing is absolute.
|
||||
assert_near(line_end(&mut s, "@10<N90dE"), 10.0, 0.0, "@10<N90dE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_numbers_are_in_aunits_and_suffixes_override() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "AUNITS", json!(3));
|
||||
assert_near(line_end(&mut s, "@10<1.5707963267948966"), 0.0, 10.0, "radians");
|
||||
assert_near(line_end(&mut s, "@10<180d"), -10.0, 0.0, "180d under radians");
|
||||
setvar(&mut s, "AUNITS", json!(2));
|
||||
assert_near(line_end(&mut s, "@10<100"), 0.0, 10.0, "grads");
|
||||
assert_near(line_end(&mut s, "@10<0.5r"), 10.0 * 0.5f64.cos(), 10.0 * 0.5f64.sin(), "0.5r under grads");
|
||||
setvar(&mut s, "AUNITS", json!(1));
|
||||
assert_near(line_end(&mut s, "@10<89d60'"), 0.0, 10.0, "deg/min");
|
||||
assert_near(line_end(&mut s, "@10<-89d59'60\""), 0.0, -10.0, "negative deg/min/sec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_reference_angles_follow_angbase_and_angdir() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "ANGBASE", json!(45));
|
||||
setvar(&mut s, "ANGDIR", json!(1));
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
// Reference 0 (= 45° here) to new angle 90 (= -45°): a quarter turn clockwise.
|
||||
for t in ["rotate", "all", "", "0,0", "r", "0", "90"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::Line(l) => assert_near(l.b.xy(), 0.0, -10.0, "ROTATE Reference 0 to 90 with ANGDIR 1"),
|
||||
k => panic!("expected a line, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_and_default_parsing_keep_degrees_from_east() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "ANGBASE", json!(90));
|
||||
setvar(&mut s, "ANGDIR", json!(1));
|
||||
setvar(&mut s, "AUNITS", json!(3));
|
||||
// JSON params are unaffected by the drawing's angle settings.
|
||||
let r = s.execute("line", &json!({ "points": ["0,0", "@10<90"] })).unwrap();
|
||||
assert!(r.is_object());
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::Line(l) => assert_near(l.b.xy(), 0.0, 10.0, "JSON @10<90"),
|
||||
k => panic!("expected a line, got {k:?}"),
|
||||
}
|
||||
assert!((cadcraft_engine::units::parse_angle("90").unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
// SETVAR reports ANGBASE in degrees.
|
||||
let v = cadcraft_engine::sysvars::get(&s, "ANGBASE").unwrap();
|
||||
assert!((v.as_f64().unwrap() - 90.0).abs() < 1e-9, "{v}");
|
||||
let a = AngleSettings { aunits: 0, angbase: 0.0, clockwise: false };
|
||||
assert_eq!(a.direction(""), None);
|
||||
assert_eq!(a.direction("nan"), None);
|
||||
assert_eq!(a.direction("1e400"), None);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Options listed at the dimension and multileader prompts take effect (#243).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{DimKind, Dimension, EntityKind, MLeader};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
|
||||
fn last(s: &Session) -> EntityKind {
|
||||
s.doc().unwrap().model.last().unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn last_dim(s: &Session) -> Dimension {
|
||||
match last(s) {
|
||||
EntityKind::Dimension(d) => d,
|
||||
other => panic!("not a dimension: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn last_mleader(s: &Session) -> MLeader {
|
||||
match last(s) {
|
||||
EntityKind::MLeader(m) => m,
|
||||
other => panic!("not a multileader: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn value(s: &Session, d: &Dimension) -> String {
|
||||
cadcraft_engine::render::dimension_in(s.doc().unwrap(), d).value
|
||||
}
|
||||
|
||||
fn near(a: Vec2, b: Vec2) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimlinear_angle_rotates_the_text_and_text_replaces_it() {
|
||||
let mut s = Session::new();
|
||||
s.script("dimlinear 0,0 10,0 a 30 t\nX=<>\n5,-3\n").unwrap();
|
||||
let d = last_dim(&s);
|
||||
assert_eq!(d.kind, DimKind::Linear { rotation: 0.0 }, "Angle must not rotate the dimension line");
|
||||
assert!((d.text_rotation - 30f64.to_radians()).abs() < 1e-9, "{}", d.text_rotation);
|
||||
assert_eq!(d.text, "X=<>");
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimlinear_vertical_forces_the_orientation() {
|
||||
let mut s = Session::new();
|
||||
s.script("dimlinear 0,0 10,5 v").unwrap();
|
||||
let p = s.prompt_text();
|
||||
assert!(p.contains("[Mtext/Text/Angle]"), "{p}");
|
||||
s.script("5,-3\n").unwrap();
|
||||
assert_eq!(last_dim(&s).kind, DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimordinate_datum_and_text_options() {
|
||||
let mut s = Session::new();
|
||||
// The leader endpoint alone would make a Y datum; Xdatum forces X, and Text is a text option.
|
||||
s.script("dimordinate 0,0 x t\nA <>\n10,2\n").unwrap();
|
||||
let d = last_dim(&s);
|
||||
assert_eq!(d.kind, DimKind::Ordinate { x_type: true });
|
||||
assert_eq!(d.text, "A <>");
|
||||
s.script("dimordinate 0,0 y a 90 2,10\n").unwrap();
|
||||
let d = last_dim(&s);
|
||||
assert_eq!(d.kind, DimKind::Ordinate { x_type: false });
|
||||
assert!((d.text_rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimradius_text_options() {
|
||||
let mut s = Session::new();
|
||||
s.script("circle 0,0 5\ndimradius 5,0 t\nR=<>\na 45 8,8\n").unwrap();
|
||||
let d = last_dim(&s);
|
||||
assert_eq!(d.kind, DimKind::Radius);
|
||||
assert_eq!(d.text, "R=<>");
|
||||
assert!((d.text_rotation - 45f64.to_radians()).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimangular_quadrant_locks_the_measured_angle() {
|
||||
let mut s = Session::new();
|
||||
// Placed at (-3,-4) the arc would measure the 270° side; the quadrant keeps the 90° one.
|
||||
s.script("dimangular\n\n0,0 10,0 0,10 q 5,5 -3,-4\n").unwrap();
|
||||
let d = last_dim(&s);
|
||||
assert_eq!(value(&s, &d), "90°");
|
||||
assert!(near(d.defpt.xy(), Vec2::new(5.0, 5.0).normalized() * 5.0), "{:?}", d.defpt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimarc_partial_measures_part_of_the_arc() {
|
||||
let mut s = Session::new();
|
||||
s.script("arc c 0,0 -10,0 10,0\n").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Arc(_)), "{:?}", last(&s));
|
||||
s.script("dimarc 0,-10 p 10,0 0,-10 5,-12\n").unwrap();
|
||||
let d = last_dim(&s);
|
||||
assert_eq!(d.kind, DimKind::ArcLength);
|
||||
assert!(near(d.p13.xy(), Vec2::new(0.0, -10.0)) && near(d.p14.xy(), Vec2::new(10.0, 0.0)), "{d:?}");
|
||||
assert_eq!(value(&s, &d), "⌒15.7080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mleader_landing_first_and_content_first() {
|
||||
let mut s = Session::new();
|
||||
s.script("mleader l 10,5 0,0 note\n").unwrap();
|
||||
let m = last_mleader(&s);
|
||||
assert_eq!(m.leaders.len(), 1);
|
||||
assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}");
|
||||
assert_eq!(m.text.unwrap().contents, "note");
|
||||
|
||||
s.script("mleader c 10,5 hello\n0,0\n").unwrap();
|
||||
let m = last_mleader(&s);
|
||||
assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}");
|
||||
assert_eq!(m.text.unwrap().contents, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mleader_options_take_effect() {
|
||||
let mut s = Session::new();
|
||||
// Enter opens Options; Maxpoints 3 and no landing line.
|
||||
s.script("mleader\n\nm 3 a n x 0,0 5,5 10,5 txt\n").unwrap();
|
||||
let m = last_mleader(&s);
|
||||
assert_eq!(m.leaders[0].len(), 2, "{m:?}");
|
||||
assert!(near(m.leaders[0][1].xy(), Vec2::new(5.0, 5.0)) && near(m.landing.xy(), Vec2::new(10.0, 5.0)));
|
||||
assert_eq!(m.dogleg, 0.0);
|
||||
assert_eq!(m.text.unwrap().contents, "txt");
|
||||
|
||||
// First angle constraint snaps the leader to 45° steps (a new session: MLEADER would keep the
|
||||
// Maxpoints and landing chosen above).
|
||||
let mut s = Session::new();
|
||||
s.script("mleader o f 45 x 0,0 10,8 note\n").unwrap();
|
||||
let m = last_mleader(&s);
|
||||
let r = Vec2::new(10.0, 8.0).len();
|
||||
assert!(near(m.landing.xy(), Vec2::new(r, r) / 2f64.sqrt()), "{:?}", m.landing);
|
||||
|
||||
// Leader type sPline keeps the following input and draws a spline leader.
|
||||
s.script("mleader o l p x 0,0 4,4 hi\n").unwrap();
|
||||
let m = last_mleader(&s);
|
||||
assert!(m.spline);
|
||||
assert_eq!(m.text.unwrap().contents, "hi");
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! ARRAY, ARRAYRECT and ARRAYPOLAR at the command line: the type prompt and the edit-array
|
||||
//! option loops. Each input is its own `cmdline` call (one typed line each).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
fn typed(s: &mut Session, inputs: &[&str]) {
|
||||
for t in inputs {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn circle_centers(s: &Session) -> Vec<Vec2> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Circle(c) => Some(c.center.xy()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has(pts: &[Vec2], x: f64, y: f64) -> bool {
|
||||
pts.iter().any(|p| p.near(Vec2::new(x, y), 1e-6))
|
||||
}
|
||||
|
||||
fn prompt(s: &Session) -> String {
|
||||
s.prompt_text()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_asks_for_the_type_and_continues_as_that_array() {
|
||||
// ARRAY ▸ POlar.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
|
||||
typed(&mut s, &["array", "l", ""]);
|
||||
assert!(prompt(&s).contains("Enter array type or [Rectangular/PAth/POlar] <Rectangular>"), "{}", prompt(&s));
|
||||
typed(&mut s, &["po", "0,0", "i", "4", "x"]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
let c = circle_centers(&s);
|
||||
assert_eq!(c.len(), 4);
|
||||
assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0));
|
||||
|
||||
// AR (alias), Enter takes the default Rectangular.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
typed(&mut s, &["ar", "l", "", ""]);
|
||||
assert!(prompt(&s).contains("COUnt"), "{}", prompt(&s));
|
||||
typed(&mut s, &["cou", "2", "1", "s", "5", "5", ""]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
let c = circle_centers(&s);
|
||||
assert_eq!(c.len(), 2);
|
||||
assert!(has(&c, 5.0, 0.0));
|
||||
|
||||
// ARRAY ▸ PAth continues at the path prompt with the selected objects.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
typed(&mut s, &["array", "0.25,1", "", "pa", "5,0", "3"]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
assert_eq!(circle_centers(&s).len(), 3);
|
||||
|
||||
// The JSON form dispatches on `type`.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
|
||||
let h = r["handle"].clone();
|
||||
let r = s.execute("array", &json!({"type": "polar", "handles": [h], "center": [0, 0], "count": 3})).unwrap();
|
||||
assert_eq!(r["created"], 2);
|
||||
assert!(s.execute("array", &json!({"type": "spiral", "handles": [h]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrayrect_edits_the_array_in_a_loop_until_exit() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
|
||||
typed(&mut s, &["arrayrect", "l", ""]);
|
||||
let p = prompt(&s);
|
||||
assert!(p.contains("Select grip to edit array or [ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit] <eXit>"), "{p}");
|
||||
// Nothing is created before eXit.
|
||||
assert_eq!(circle_centers(&s).len(), 1);
|
||||
typed(&mut s, &["cou", "2", "3", "s", "10", "20"]);
|
||||
// Unavailable options report and return to the loop without taking the next input.
|
||||
typed(&mut s, &["as", "l", "cou", "2", "3"]);
|
||||
assert!(prompt(&s).contains("Select grip to edit array"), "{}", prompt(&s));
|
||||
typed(&mut s, &["x"]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
let c = circle_centers(&s);
|
||||
assert_eq!(c.len(), 6, "{c:?}");
|
||||
for (x, y) in [(10.0, 0.0), (0.0, 20.0), (10.0, 40.0)] {
|
||||
assert!(has(&c, x, y), "{c:?}");
|
||||
}
|
||||
|
||||
// COLumns with a Total distance, Rows with spacing and elevation.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
|
||||
typed(&mut s, &["arrayrect", "l", "", "col", "3", "t", "20", "r", "2", "5", "0", "x"]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
let c = circle_centers(&s);
|
||||
assert_eq!(c.len(), 6, "{c:?}");
|
||||
assert!(has(&c, 20.0, 0.0) && has(&c, 10.0, 5.0), "{c:?}");
|
||||
|
||||
// Invalid counts re-prompt.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
|
||||
typed(&mut s, &["arrayrect", "l", "", "cou", "0"]);
|
||||
assert!(prompt(&s).contains("number of columns"), "{}", prompt(&s));
|
||||
typed(&mut s, &["2", "1", "x"]);
|
||||
assert_eq!(circle_centers(&s).len(), 2);
|
||||
|
||||
// The JSON form is unchanged.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap();
|
||||
let r = s.execute("arrayrect", &json!({"handles": [r["handle"]], "rows": 2, "cols": 3, "rowSpacing": 4, "colSpacing": 2})).unwrap();
|
||||
assert_eq!(r["created"], 5);
|
||||
assert!(has(&circle_centers(&s), 4.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arraypolar_asks_for_the_center_then_edits_items_and_angles() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
|
||||
typed(&mut s, &["arraypolar", "l", ""]);
|
||||
assert!(prompt(&s).contains("Specify center point of array or [Base point/Axis of rotation]"), "{}", prompt(&s));
|
||||
typed(&mut s, &["0,0"]);
|
||||
let p = prompt(&s);
|
||||
assert!(
|
||||
p.contains("Select grip to edit array or [ASsociative/Base point/Items/Angle between/Fill angle/ROWs/Levels/ROTate items/eXit] <eXit>"),
|
||||
"{p}"
|
||||
);
|
||||
typed(&mut s, &["i", "4", "x"]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
// X ended ARRAYPOLAR; it did not start EXPLODE.
|
||||
assert!(!s.log.iter().any(|l| l.contains("EXPLODE")));
|
||||
let c = circle_centers(&s);
|
||||
assert_eq!(c.len(), 4);
|
||||
assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0));
|
||||
|
||||
// Angle between with 4 items: 30° apart, filling 90°.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap();
|
||||
s.script("ARRAYPOLAR L\n\n0,0 I 4 A 30 X\n").unwrap();
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
let c = circle_centers(&s);
|
||||
assert_eq!(c.len(), 4);
|
||||
assert!(has(&c, 0.0, 10.0) && !has(&c, -10.0, 0.0), "{c:?}");
|
||||
|
||||
// Fill angle 180 with 3 items, items not rotated: the copies only move.
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[10, 0], [12, 0]]})).unwrap();
|
||||
typed(&mut s, &["arraypolar", "l", "", "0,0", "i", "3", "f", "180", "rot", "n", "x"]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
let lines: Vec<(Vec2, Vec2)> = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
// The middle copy: the line's centre (11,0) moved to (0,11), still horizontal.
|
||||
assert!(lines.iter().any(|(a, b)| a.near(Vec2::new(-1.0, 11.0), 1e-6) && b.near(Vec2::new(1.0, 11.0), 1e-6)), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_over_maxarray_are_refused_not_shrunk() {
|
||||
// JSON: a million × a million rows/cols (or items) is refused up front, nothing is created.
|
||||
let mut s = Session::new();
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
let h = c["handle"].clone();
|
||||
let before = s.doc().unwrap().model.iter().count();
|
||||
let e = s.execute("arrayrect", &json!({"handles": [h], "rows": 1_000_000, "cols": 1_000_000})).unwrap_err();
|
||||
assert!(e.to_string().contains("MAXARRAY"), "{e}");
|
||||
assert!(s.execute("arraypolar", &json!({"handles": [h], "center": [5, 5], "count": 200_000})).unwrap_err().to_string().contains("MAXARRAY"));
|
||||
let path = s.execute("line", &json!({"points": [[0, 5], [100, 5]]})).unwrap()["handles"][0].clone();
|
||||
let e = s.execute("arraypath", &json!({"handles": [h], "path": path, "spacing": 1e-6})).unwrap_err();
|
||||
assert!(e.to_string().contains("MAXARRAY"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), before + 1);
|
||||
|
||||
// The limit counts every selected object, and MAXARRAY is a settable system variable.
|
||||
s.execute("setvar", &json!({"name": "MAXARRAY", "value": 100})).unwrap();
|
||||
let h2 = s.execute("circle", &json!({"center": [3, 0], "radius": 1})).unwrap()["handle"].clone();
|
||||
assert!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 6})).is_err());
|
||||
assert_eq!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 5})).unwrap()["created"], 98);
|
||||
|
||||
// Interactive: a count over the limit is rejected at its prompt and the loop keeps going.
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
typed(&mut s, &["arrayrect", "l", "", "cou", "1000", "1000"]);
|
||||
assert!(s.log.iter().any(|l| l.contains("MAXARRAY")), "{:?}", s.log);
|
||||
assert!(prompt(&s).contains("rows"), "{}", prompt(&s));
|
||||
typed(&mut s, &["2", ""]);
|
||||
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
|
||||
assert_eq!(circle_centers(&s).len(), 2000);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! ATTDISP (ATTMODE, honoured by the display) and ATTSYNC.
|
||||
|
||||
use cadcraft_doc::{Attrib, EntityKind, Handle, Space};
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Make block `name` from what is in model space now (the objects are removed).
|
||||
fn make_block(s: &mut Session, name: &str) {
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
}
|
||||
|
||||
fn attdef(s: &mut Session, tag: &str, default: &str, y: f64, height: f64, invisible: bool) {
|
||||
s.execute("attdef", &json!({"tag": tag, "default": default, "at": [0, y], "height": height, "invisible": invisible})).unwrap();
|
||||
}
|
||||
|
||||
fn line(s: &mut Session) {
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, x: f64, attribs: Value) -> Handle {
|
||||
let r = s.execute("insert", &json!({"name": "B", "at": [x, 0], "attribs": attribs})).unwrap();
|
||||
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn attribs(s: &Session, h: Handle) -> Vec<Attrib> {
|
||||
let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };
|
||||
i.attribs.clone()
|
||||
}
|
||||
|
||||
fn segments(s: &Session) -> usize {
|
||||
cadcraft_render::build(s.doc().unwrap(), &Space::Model, &cadcraft_render::Options::default()).segment_count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attdisp_sets_attmode_and_the_display_follows_it() {
|
||||
let mut s = Session::new();
|
||||
attdef(&mut s, "T", "shown", 1.0, 1.0, false);
|
||||
attdef(&mut s, "H", "hidden", 3.0, 1.0, true);
|
||||
line(&mut s);
|
||||
make_block(&mut s, "B");
|
||||
insert(&mut s, 0.0, json!({}));
|
||||
let normal = segments(&s);
|
||||
|
||||
s.cmdline("attdisp").unwrap();
|
||||
assert!(s.prompt_text().contains("[Normal/ON/OFF] <Normal>"), "{}", s.prompt_text());
|
||||
s.cmdline("on").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 2);
|
||||
let on = segments(&s);
|
||||
|
||||
s.cmdline("attdisp off").unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 0);
|
||||
let off = segments(&s);
|
||||
assert!(off < normal && normal < on, "off {off}, normal {normal}, on {on}");
|
||||
|
||||
assert_eq!(s.execute("attdisp", &json!({"mode": "normal"})).unwrap(), json!({"attmode": 1}));
|
||||
assert_eq!(segments(&s), normal);
|
||||
assert!(s.execute("attdisp", &json!({"mode": "sometimes"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attsync_brings_references_in_line_with_the_definitions() {
|
||||
let mut s = Session::new();
|
||||
attdef(&mut s, "A", "a", 1.0, 0.2, false);
|
||||
attdef(&mut s, "OLD", "old", 2.0, 0.2, false);
|
||||
line(&mut s);
|
||||
make_block(&mut s, "B");
|
||||
let r1 = insert(&mut s, 100.0, json!({"A": "kept", "OLD": "x"}));
|
||||
let r2 = insert(&mut s, 200.0, json!({}));
|
||||
// A reference nested in another block.
|
||||
let nested = insert(&mut s, 0.0, json!({"A": "inner"}));
|
||||
s.execute("block", &json!({"name": "N", "base": [0, 0], "handles": [nested.hex()], "keep": "delete"})).unwrap();
|
||||
|
||||
// Redefine B: A moves and grows and turns invisible, OLD goes, NEW comes.
|
||||
attdef(&mut s, "A", "a", 5.0, 0.5, true);
|
||||
attdef(&mut s, "NEW", "new", 7.0, 0.2, false);
|
||||
line(&mut s);
|
||||
let others: Vec<String> = s.doc().unwrap().model.iter().filter(|e| ![r1, r2].contains(&e.handle)).map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": others, "keep": "delete"})).unwrap();
|
||||
|
||||
// Name option, typed as at the command line.
|
||||
s.cmdline("attsync").unwrap();
|
||||
assert!(s.prompt_text().contains("[?/Name/Select] <Select>"), "{}", s.prompt_text());
|
||||
for l in ["n", "b"] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(s.log.iter().any(|l| l == "ATTSYNC block B: 3 references synchronized."), "{:?}", s.log);
|
||||
|
||||
let a = attribs(&s, r1);
|
||||
let tags: Vec<&str> = a.iter().map(|a| a.tag.as_str()).collect();
|
||||
assert_eq!(tags, ["A", "NEW"]);
|
||||
assert_eq!((a[0].text.value.as_str(), a[1].text.value.as_str()), ("kept", "new"));
|
||||
assert_eq!((a[0].text.insert.x, a[0].text.insert.y, a[0].text.height), (100.0, 5.0, 0.5));
|
||||
assert!(a[0].invisible);
|
||||
assert_eq!(attribs(&s, r2)[0].text.value, "a");
|
||||
|
||||
let n = s.doc().unwrap().block("N").unwrap().clone();
|
||||
let inner: Vec<Attrib> =
|
||||
n.entities.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.attribs.clone()) } else { None }).unwrap();
|
||||
assert_eq!(inner.iter().map(|a| (a.tag.as_str(), a.text.value.as_str())).collect::<Vec<_>>(), [("A", "inner"), ("NEW", "new")]);
|
||||
|
||||
// Select option (the default), confirmed with Enter; one undo step per run.
|
||||
s.execute("attedit", &json!({"handle": r2.hex(), "values": {"A": "changed"}})).unwrap();
|
||||
for l in ["attsync", "", "205,0", ""] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(attribs(&s, r2)[0].text.value, "changed");
|
||||
|
||||
// The JSON form, by a reference's handle.
|
||||
assert_eq!(s.execute("attsync", &json!({"handle": r1.hex()})).unwrap(), json!({"block": "B", "references": 3}));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! ATTEDIT / -ATTEDIT typed at the command line, and attribute tags matched in any case.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Block B: a line from 0,0 to 10,0 with attributes T (default "one") and U (default "u").
|
||||
fn with_block() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("attdef", &json!({"tag": "T", "default": "one", "at": [0, 1]})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "U", "default": "u", "at": [0, 2]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, x: f64, attribs: Value) -> Value {
|
||||
s.execute("insert", &json!({"name": "B", "at": [x, 0], "attribs": attribs})).unwrap()
|
||||
}
|
||||
|
||||
fn handle(r: &Value) -> Handle {
|
||||
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn value(s: &Session, h: Handle, tag: &str) -> String {
|
||||
let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };
|
||||
i.attribs.iter().find(|a| a.tag == tag).unwrap().text.value.clone()
|
||||
}
|
||||
|
||||
fn type_lines(s: &mut Session, lines: &[&str]) {
|
||||
for l in lines {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attedit_asks_for_a_block_then_each_value() {
|
||||
let mut s = with_block();
|
||||
let h = handle(&insert(&mut s, 100.0, json!({})));
|
||||
s.cmdline("attedit").unwrap();
|
||||
assert!(s.prompt_text().contains("Select block reference"), "{}", s.prompt_text());
|
||||
s.cmdline("105,0").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter value for T <one>"), "{}", s.prompt_text());
|
||||
// A value with spaces; Enter keeps U as it was.
|
||||
type_lines(&mut s, &["new value", ""]);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(value(&s, h, "T"), "new value");
|
||||
assert_eq!(value(&s, h, "U"), "u");
|
||||
// One undo step.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(value(&s, h, "T"), "one");
|
||||
|
||||
// EATTEDIT is the same command; a pick on nothing editable re-prompts.
|
||||
s.execute("line", &json!({"points": [[0, 50], [10, 50]]})).unwrap();
|
||||
type_lines(&mut s, &["eattedit", "5,50"]);
|
||||
assert!(s.log.iter().any(|l| l.contains("Select a block reference.")), "{:?}", s.log);
|
||||
type_lines(&mut s, &["105,0", "x", "y"]);
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("x".into(), "y".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_attedit_global_replaces_a_string_in_matching_attributes() {
|
||||
let mut s = with_block();
|
||||
let a = handle(&insert(&mut s, 100.0, json!({"T": "A-1", "U": "A-9"})));
|
||||
let b = handle(&insert(&mut s, 200.0, json!({"T": "A-2"})));
|
||||
// Not one at a time, not only visible ones: every reference of B, tag T, any value.
|
||||
s.script("-attedit\nn\nn\nb\nt\n\nA-\nX-\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("X-1".into(), "X-2".into()));
|
||||
assert_eq!(value(&s, a, "U"), "A-9", "tag specification filters");
|
||||
assert!(s.log.iter().any(|l| l == "2 attributes changed."), "{:?}", s.log);
|
||||
|
||||
// The JSON form does the same.
|
||||
let r = s.execute("-attedit", &json!({"tag": "u", "find": "9", "replace": "8"})).unwrap();
|
||||
assert_eq!(r["changed"], 1);
|
||||
assert_eq!(value(&s, a, "U"), "A-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_attedit_one_at_a_time_edits_the_selected_attributes() {
|
||||
let mut s = with_block();
|
||||
let h = handle(&insert(&mut s, 100.0, json!({"T": "abc"})));
|
||||
type_lines(&mut s, &["-attedit", "y", "", "T", "", "105,0", ""]);
|
||||
assert!(s.prompt_text().contains("[Value/Layer/Color/Next] <N>"), "{}", s.prompt_text());
|
||||
// Unsupported options say so and keep the prompt.
|
||||
s.cmdline("p").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Position is not available yet")), "{:?}", s.log);
|
||||
type_lines(&mut s, &["v", "c", "b", "B", "v", "r", "replaced value", "n"]);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(value(&s, h, "T"), "replaced value");
|
||||
assert_eq!(value(&s, h, "U"), "u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_tags_match_in_any_case_and_unknown_tags_are_reported() {
|
||||
let mut s = with_block();
|
||||
let r = insert(&mut s, 100.0, json!({"t": "two", "nope": "x"}));
|
||||
let h = handle(&r);
|
||||
assert_eq!(value(&s, h, "T"), "two");
|
||||
assert_eq!(r["unknownTags"], json!(["nope"]));
|
||||
|
||||
let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"t": "three", "u": 4, "W": "w"}})).unwrap();
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(r["unknownTags"], json!(["W"]));
|
||||
assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("three".into(), "4".into()));
|
||||
|
||||
let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "four"}})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 1}));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Layer and colour set on one attribute of a block reference: -ATTEDIT Layer/Color, drawn in
|
||||
//! place of the definition's, reset by ATTSYNC.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
use cadcraft_engine::doc::{AttribProps, EntityKind, Handle, Space};
|
||||
use cadcraft_engine::render::{Options, build};
|
||||
use serde_json::json;
|
||||
|
||||
/// Block B (attributes T and U on layer 0) inserted at 100,0; layer A exists.
|
||||
fn setup() -> (Session, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("attdef", &json!({"tag": "T", "default": "one", "at": [0, 1]})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "U", "default": "u", "at": [0, 2]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
let r = s.execute("insert", &json!({"name": "B", "at": [100, 0]})).unwrap();
|
||||
s.doc_mut().unwrap().ensure_layer("A");
|
||||
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
fn props(s: &Session, h: Handle, tag: &str) -> AttribProps {
|
||||
let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };
|
||||
i.attribs.iter().find(|a| a.tag == tag).unwrap().props.clone()
|
||||
}
|
||||
|
||||
fn drawn_in(s: &Session, rgb: Rgb) -> bool {
|
||||
let l = build(s.doc().unwrap(), &Space::Model, &Options::default());
|
||||
l.prims.iter().any(|p| p.color == rgb)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_attedit_sets_layer_and_colour_of_one_attribute() {
|
||||
let (mut s, h) = setup();
|
||||
let blue = Rgb(0, 128, 255);
|
||||
assert!(!drawn_in(&s, blue));
|
||||
for t in ["-attedit", "y", "", "T", "", "105,0", ""] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.prompt_text().contains("[Value/Layer/Color/Next] <N>"), "{}", s.prompt_text());
|
||||
s.cmdline("l").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter new layer name <0>"), "{}", s.prompt_text());
|
||||
// An unknown layer is refused and asked again.
|
||||
s.cmdline("nope").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Cannot find layer \"nope\"")), "{:?}", s.log);
|
||||
for t in ["a", "c", "1", "c"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.prompt_text().contains("<Red>"), "{}", s.prompt_text());
|
||||
for t in ["t", "0,128,255"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text());
|
||||
s.cmdline("n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
let p = props(&s, h, "T");
|
||||
assert_eq!((p.layer.as_deref(), p.color), (Some("A"), Some(Color::True(blue))));
|
||||
assert!(props(&s, h, "U").is_empty(), "only the edited attribute changes");
|
||||
assert!(drawn_in(&s, blue), "the attribute's own colour is drawn");
|
||||
|
||||
// ATTSYNC brings the attributes back to their definitions, as in AutoCAD.
|
||||
s.execute("attsync", &json!({"name": "B"})).unwrap();
|
||||
assert!(props(&s, h, "T").is_empty());
|
||||
assert!(!drawn_in(&s, blue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_answers_do_not_panic() {
|
||||
let (mut s, h) = setup();
|
||||
for t in ["-attedit", "y", "", "", "", "105,0", "", "c", "999", "co", "t", "1,2", "300,0,0", "", "l", "", "n", "n"] {
|
||||
let _ = s.cmdline(t);
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(props(&s, h, "T").is_empty() && props(&s, h, "U").is_empty());
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! AUDIT finds and fixes database errors; RECOVER opens a damaged drawing with the fixes applied
|
||||
//! (#406, docs/gaps.md F4).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_io};
|
||||
use cadcraft_engine::doc::{AssocSnap, Block, Circle, Common, DimAssoc, Drawing, Entity, EntityKind, Group, HVal, Handle, Insert, Line, Space, Text};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
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) })
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> EntityKind {
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn on(layer: &str) -> Common {
|
||||
Common { layer: layer.into(), ..Common::default() }
|
||||
}
|
||||
|
||||
/// Push an object without the checks `Drawing::add` makes (it would create the layer).
|
||||
fn raw(d: &mut Drawing, space: &Space, common: Common, kind: EntityKind) -> Handle {
|
||||
let h = d.new_handle();
|
||||
d.space_mut(space).unwrap().push(Entity { handle: h, common, kind });
|
||||
h
|
||||
}
|
||||
|
||||
/// A drawing with one of each error AUDIT knows about, plus a good line and an associative
|
||||
/// dimension. Returns the handles of the good line and the dimension.
|
||||
fn corrupt(d: &mut Drawing) -> (Handle, Handle) {
|
||||
let good = raw(d, &Space::Model, on("0"), line((0.0, 0.0), (10.0, 0.0)));
|
||||
raw(d, &Space::Model, on("Ghost"), line((0.0, 5.0), (10.0, 5.0)));
|
||||
raw(
|
||||
d,
|
||||
&Space::Model,
|
||||
Common { linetype: "NoSuchDash".into(), ..on("0") },
|
||||
EntityKind::Circle(Circle { center: Vec3::new(3.0, 3.0, 0.0), radius: 1.0 }),
|
||||
);
|
||||
raw(d, &Space::Model, on("0"), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.0 }));
|
||||
raw(d, &Space::Model, on("0"), line((f64::NAN, 0.0), (1.0, 1.0)));
|
||||
raw(d, &Space::Model, on("0"), line((2.0, 2.0), (2.0, 2.0)));
|
||||
raw(
|
||||
d,
|
||||
&Space::Model,
|
||||
on("0"),
|
||||
EntityKind::Text(Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "A".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Vanished".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
);
|
||||
raw(d, &Space::Model, on("0"), insert("Nowhere"));
|
||||
// A and B contain each other; C contains itself.
|
||||
for (name, inner) in [("A", "B"), ("B", "A"), ("C", "C")] {
|
||||
let mut b = Block::new(name);
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity { handle: h, common: on("0"), kind: insert(inner) });
|
||||
d.blocks.insert(name.into(), Arc::new(b));
|
||||
}
|
||||
raw(d, &Space::Model, on("0"), insert("A"));
|
||||
// The same handle in model space and in a layout.
|
||||
let dup = d.model.iter().nth(2).unwrap().handle;
|
||||
d.space_mut(&Space::Paper("Layout1".into())).unwrap().push(Entity { handle: dup, common: on("0"), kind: line((0.0, 0.0), (1.0, 1.0)) });
|
||||
d.groups.push(Group { name: "G".into(), description: String::new(), selectable: true, members: vec![good, Handle(0xDEAD)] });
|
||||
d.header.set("CLAYER", HVal::Str("Gone".into()));
|
||||
d.header.set_str("DIMSTYLE", "Lost");
|
||||
let dim = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).map(|e| e.handle).unwrap();
|
||||
d.modify_entity(dim, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.assoc.push(DimAssoc { point: "p13".into(), handle: Handle(0xBEEF), snap: AssocSnap::Start });
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
(good, dim)
|
||||
}
|
||||
|
||||
/// A session whose drawing has a dimension, then every error from [`corrupt`].
|
||||
fn corrupt_session() -> (Session, Handle, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [4, 0], "at": [2, 2]})).unwrap();
|
||||
let mut d = (*s.doc().unwrap()).clone();
|
||||
let (good, dim) = corrupt(&mut d);
|
||||
let st = s.state_mut().unwrap();
|
||||
st.doc = Arc::new(d);
|
||||
st.saved = st.doc.clone();
|
||||
(s, good, dim)
|
||||
}
|
||||
|
||||
fn kinds(r: &Value) -> Vec<String> {
|
||||
r["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_reports_without_changing_and_fixes_when_asked() {
|
||||
let (mut s, good, dim) = corrupt_session();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
|
||||
let r = s.execute("audit", &json!({})).unwrap();
|
||||
let k = kinds(&r);
|
||||
for want in [
|
||||
"missingLayer",
|
||||
"missingReference",
|
||||
"invalidGeometry",
|
||||
"duplicateHandle",
|
||||
"missingBlock",
|
||||
"blockCycle",
|
||||
"danglingReference",
|
||||
"missingCurrent",
|
||||
] {
|
||||
assert!(k.iter().any(|x| x == want), "{want} not reported: {r:#}");
|
||||
}
|
||||
// NaN line, zero radius circle, zero length line.
|
||||
assert_eq!(k.iter().filter(|x| *x == "invalidGeometry").count(), 3, "{r:#}");
|
||||
assert_eq!(r["fixed"], 0);
|
||||
assert!(r["errors"].as_u64().unwrap() >= 12);
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc), "a report without fix changes nothing");
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo);
|
||||
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert_eq!(r["fixed"], r["errors"]);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.layer("Ghost").is_some(), "missing layer recreated");
|
||||
assert!(d.block("Nowhere").is_some_and(|b| b.entities.is_empty()), "missing block recreated empty");
|
||||
assert_eq!(d.header.str("CLAYER", ""), "0");
|
||||
assert_eq!(d.header.str("DIMSTYLE", ""), "Standard");
|
||||
assert_eq!(d.groups[0].members, vec![good]);
|
||||
let EntityKind::Dimension(dm) = &d.entity(dim).unwrap().kind else { panic!() };
|
||||
assert!(dm.assoc.iter().all(|a| a.handle != Handle(0xBEEF)));
|
||||
for e in d.model.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) => assert_eq!(t.style, "Standard"),
|
||||
EntityKind::Circle(c) => {
|
||||
assert!(c.radius > 0.0);
|
||||
assert_eq!(e.common.linetype, "ByLayer");
|
||||
}
|
||||
EntityKind::Line(l) => assert!(l.a != l.b && l.a.x.is_finite()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Every handle is unique and below the next one.
|
||||
let mut all: Vec<Handle> = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| e.handle).collect();
|
||||
all.extend(d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)));
|
||||
let n = all.len();
|
||||
all.sort();
|
||||
all.dedup();
|
||||
assert_eq!(all.len(), n, "duplicate handles remain");
|
||||
assert!(all.iter().all(|h| h.0 < d.handseed));
|
||||
|
||||
// Clean now; one undo step brings the errors back.
|
||||
assert_eq!(s.execute("audit", &json!({})).unwrap()["errors"], 0);
|
||||
assert!(s.state().unwrap().is_dirty());
|
||||
s.undo().unwrap();
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_drawing_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [5, 5]]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [1, 1], "radius": 2})).unwrap();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert_eq!((r["errors"].as_u64(), r["fixed"].as_u64()), (Some(0), Some(0)));
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for nothing");
|
||||
|
||||
// What the sample drawings and the drawing commands make is not an error.
|
||||
for d in [cadcraft_engine::sample::bracket(), cadcraft_engine::sample::floor_plan()] {
|
||||
let r = cadcraft_engine::audit::check(&d);
|
||||
assert!(r.issues.is_empty(), "{}", r.message());
|
||||
}
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [4, 0], "at": [2, 2]})).unwrap();
|
||||
s.execute(
|
||||
"dimradius",
|
||||
&json!({"handle": s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Circle(_))).unwrap().handle.hex(), "at": [4, 4]}),
|
||||
)
|
||||
.ok();
|
||||
s.script("mtext 0,0 5,-5\nNote\n\nhatch p solid 0,0 \n").ok();
|
||||
s.execute("block", &json!({"name": "Bolt", "base": [0, 0], "handles": s.doc().unwrap().model.handles()[..2]})).unwrap();
|
||||
s.execute("insert", &json!({"name": "Bolt", "at": [20, 20]})).unwrap();
|
||||
let r = s.execute("audit", &json!({})).unwrap();
|
||||
assert_eq!(r["errors"], 0, "{r:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_at_the_command_line_asks_before_fixing() {
|
||||
let (mut s, _, _) = corrupt_session();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
s.cmdline("audit").unwrap();
|
||||
assert_eq!(s.prompt_text(), "AUDIT Fix any errors detected? [Yes/No] <N>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc), "Enter takes No");
|
||||
assert!(s.log.iter().any(|l| l.contains("Run AUDIT and answer Yes")), "{:?}", s.log);
|
||||
|
||||
s.cmdline("audit").unwrap();
|
||||
s.cmdline("y").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(!Arc::ptr_eq(&before, &s.state().unwrap().doc));
|
||||
let summary = s.log.iter().rev().find(|l| l.starts_with("Audit:")).unwrap();
|
||||
assert!(summary.contains("fixed") && !summary.contains(" 0 fixed"), "{summary}");
|
||||
assert_eq!(s.execute("audit", &json!({})).unwrap()["errors"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_standard_entries_and_layer_zero_are_recreated() {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.layers.retain(|l| l.name != "0");
|
||||
d.text_styles.clear();
|
||||
d.dim_styles.clear();
|
||||
d.linetypes.retain(|l| !l.name.eq_ignore_ascii_case("Continuous"));
|
||||
d.layers.push(cadcraft_engine::doc::Layer { linetype: "Dashed9".into(), ..cadcraft_engine::doc::Layer::new("L1") });
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert!(r["errors"].as_u64().unwrap() >= 5, "{r:#}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.layer("0").is_some() && d.text_style("Standard").is_some() && d.dim_style("Standard").is_some());
|
||||
assert!(d.linetype("Continuous").is_some());
|
||||
assert_eq!(d.layer("L1").unwrap().linetype, "Continuous");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_block_cycle_is_broken_without_deep_recursion() {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let n = 20_000;
|
||||
for i in 0..n {
|
||||
let mut b = Block::new(&format!("B{i}"));
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity { handle: h, common: on("0"), kind: insert(&format!("B{}", (i + 1) % n)) });
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let r = s.execute("audit", &json!({"fix": true})).unwrap();
|
||||
assert_eq!(kinds(&r).iter().filter(|k| *k == "blockCycle").count(), 1, "one reference closes the loop");
|
||||
assert_eq!(s.execute("audit", &json!({})).unwrap()["errors"], 0);
|
||||
}
|
||||
|
||||
fn read_corrupt(_: &[u8], name: &str) -> Result<Drawing, String> {
|
||||
if name.ends_with(".bad") {
|
||||
return Err("not a drawing".into());
|
||||
}
|
||||
let mut d = Drawing::new_imperial();
|
||||
raw(&mut d, &Space::Model, on("Ghost"), line((0.0, 0.0), (1.0, 0.0)));
|
||||
raw(&mut d, &Space::Model, on("0"), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: -1.0 }));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_opens_the_file_with_the_fixes_applied() {
|
||||
set_io(IoHooks { read: read_corrupt, write: |_, _| Ok(Vec::new()), plot: None });
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("recover", &json!({"data": base64_encode(b"0\nEOF\n"), "name": "plan.dxf"})).unwrap();
|
||||
assert_eq!(r["errors"], 2, "{r:#}");
|
||||
assert_eq!(r["fixed"], 2);
|
||||
assert_eq!(r["index"], 1);
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!(st.title, "plan.dxf");
|
||||
assert!(st.is_dirty(), "the fixes are unsaved changes");
|
||||
assert!(st.doc.layer("Ghost").is_some());
|
||||
assert_eq!(st.doc.model.len(), 1);
|
||||
|
||||
// By path, typed at the command line.
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-recover-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("site.dxf");
|
||||
std::fs::write(&path, b"0\nEOF\n").unwrap();
|
||||
s.cmdline("recover").unwrap();
|
||||
assert_eq!(s.prompt_text(), "RECOVER Enter name of drawing file to recover:");
|
||||
s.cmdline(&path.to_string_lossy()).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!((st.title.as_str(), st.path.as_deref()), ("site.dxf", Some(path.to_string_lossy().as_ref())));
|
||||
assert!(s.log.iter().any(|l| l == "Audit: 2 errors found, 2 fixed."), "{:?}", s.log);
|
||||
|
||||
// A file the reader rejects is an error; nothing opens.
|
||||
let docs = s.docs.len();
|
||||
assert!(s.execute("recover", &json!({"data": "", "name": "x.bad"})).is_err());
|
||||
assert!(s.execute("recover", &json!({})).is_err());
|
||||
assert_eq!(s.docs.len(), docs);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! BLOCK redefinition: no self-referencing blocks, names are case-insensitive (#250).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Kinds of the entities in block `name`, as "line"/"insert:<block>".
|
||||
fn contents(s: &Session, name: &str) -> Vec<String> {
|
||||
let b = s.doc().unwrap().block(name).unwrap().clone();
|
||||
b.entities
|
||||
.iter()
|
||||
.map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => format!("insert:{}", i.block),
|
||||
EntityKind::Line(_) => "line".to_string(),
|
||||
_ => "other".to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_cannot_be_redefined_to_reference_itself() {
|
||||
let mut s = Session::new();
|
||||
// Command line: -BLOCK redefines B (Yes) from the reference BLOCK converted plus a new line.
|
||||
s.script("LINE 0,0 10,0\n\nBLOCK B 0,0 L\n\nLINE 0,0 0,10\n\n-BLOCK B Y 0,0 ALL\n\n").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("references itself")), "{:?}", s.log);
|
||||
assert_eq!(contents(&s, "B"), ["line"], "B keeps its original definition");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2, "the reference and the new line stay in the drawing");
|
||||
|
||||
// Through a nested block: A holds B's reference, B holds A's.
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let a = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [l] })).unwrap()["insert"].clone();
|
||||
let l = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
let b = s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, l] })).unwrap()["insert"].clone();
|
||||
let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
|
||||
assert_eq!(contents(&s, "A"), ["line"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_with_a_different_case_replaces_the_block() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
let l = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
s.execute("block", &json!({ "name": "DOOR", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap();
|
||||
let list = s.execute("blocks.list", &json!({})).unwrap();
|
||||
let names: Vec<&str> = list["blocks"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Door"]);
|
||||
assert_eq!(s.doc().unwrap().blocks.values().map(|b| b.entities.len()).collect::<Vec<_>>(), [1]);
|
||||
let v: Vec<Value> = s.doc().unwrap().block("Door").unwrap().entities.iter().map(|e| serde_json::to_value(&e.kind).unwrap()).collect();
|
||||
assert_eq!(v[0]["b"]["y"], json!(10.0), "the new definition replaced the old one");
|
||||
}
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user