mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-22 02:04:01 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e86eacf094 | ||
|
|
0b32c2e6a1 | ||
|
|
323f4629eb | ||
|
|
c02027abc8 | ||
|
|
8c249aa722 | ||
|
|
a0aa0863bf | ||
|
|
b4509d2368 | ||
|
|
13a3ebc0d1 | ||
|
|
41e0860725 | ||
|
|
0d23cc3cb3 | ||
|
|
81acc9b8e4 | ||
|
|
397b8e156a | ||
|
|
12567f14f2 | ||
|
|
95e86caea5 | ||
|
|
7adf8d89cd | ||
|
|
006f71f650 | ||
|
|
b0ed57f1c7 | ||
|
|
30db7296c4 | ||
|
|
a76775bf2b | ||
|
|
6576cac1a3 | ||
|
|
4125b351cd | ||
|
|
6d15a40f43 | ||
|
|
5f390e6e6a | ||
|
|
00f521858a | ||
|
|
bcd0b3745e | ||
|
|
ba27f36b58 | ||
|
|
9f7a2422ea | ||
|
|
148091aa93 | ||
|
|
c82d6228ff | ||
|
|
786c8c46e4 | ||
|
|
62329310d7 | ||
|
|
6bd09ea468 | ||
|
|
0440fa77ff | ||
|
|
471478868d | ||
|
|
0f833358b9 | ||
|
|
64e55f903c | ||
|
|
94aad92f1d | ||
|
|
1738d3805e | ||
|
|
bf6d9fd46a | ||
|
|
f17be3d7ad | ||
|
|
582d9535b6 | ||
|
|
2a3b0c2626 | ||
|
|
abce3a718b | ||
|
|
acb28313c6 | ||
|
|
2be9d00796 | ||
|
|
286d3de0aa | ||
|
|
cd3e8478d6 | ||
|
|
12d0235b2c | ||
|
|
1ac2ebaad2 | ||
|
|
a3b3e1281f | ||
|
|
ca5a34be12 | ||
|
|
c400d6d48c | ||
|
|
e635248b1e | ||
|
|
739e083f4b | ||
|
|
3fa7275e4f | ||
|
|
aba903dbb3 | ||
|
|
493addc2dd | ||
|
|
a2bee8b49e | ||
|
|
c68420221c | ||
|
|
a5fa5ca064 | ||
|
|
ef599e58e0 | ||
|
|
27fd074f54 | ||
|
|
2db6ab069a | ||
|
|
d33a6e634a | ||
|
|
0bfe609567 | ||
|
|
7e830044ab | ||
|
|
6546f0eecd | ||
|
|
15b5395c06 | ||
|
|
cdc61f32b5 | ||
|
|
d4bacce885 | ||
|
|
9aea9b16c4 | ||
|
|
9e3f584a68 | ||
|
|
3bd7510f2b | ||
|
|
b464a947ec | ||
|
|
f76ca751cc | ||
|
|
e0ad0ef33e | ||
|
|
56d73ceb21 | ||
|
|
691bdbe0b8 | ||
|
|
162d5c6820 | ||
|
|
632c2d4f1f | ||
|
|
f0e4fef003 | ||
|
|
7164b6b5b1 | ||
|
|
38fdfa4ac4 | ||
|
|
baa9c00fb7 | ||
|
|
0cc66ad306 | ||
|
|
3370a91fe9 | ||
|
|
b23d362f6a | ||
|
|
714111d528 | ||
|
|
ff6cc8b3fc | ||
|
|
98d22f0c68 | ||
|
|
ba02572b31 | ||
|
|
8fe7c421ea | ||
|
|
972ae7888c | ||
|
|
9b2308b138 | ||
|
|
69fc442ea6 | ||
|
|
be3ce73895 |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "27 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
@@ -0,0 +1,30 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-dir: CADCraft
|
||||
@@ -68,3 +68,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
|
||||
Generated
+23
-16
@@ -547,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -569,7 +569,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -580,14 +580,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -597,7 +597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -608,14 +608,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -624,7 +624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -634,6 +634,7 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"log",
|
||||
"rstar",
|
||||
"serde",
|
||||
@@ -643,7 +644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
@@ -653,15 +654,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
@@ -679,7 +681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -690,7 +692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -704,7 +706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -726,7 +728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -927,6 +929,11 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -4804,7 +4811,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -24,6 +24,8 @@ cadcraft-io = { path = "crates/io" }
|
||||
cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<h1 align="center">CADCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust.</b>
|
||||
<b>Computer-aided design and drafting; an open-source, modern 2D and 3D CAD application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
|
||||
A fast, open-source CAD workflow: the command line, object snaps, layers,
|
||||
dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
|
||||
Windows, Linux and FreeBSD, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
@@ -111,7 +111,7 @@ numbers):
|
||||
| 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, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska) and Arabic (العربية, with an installed Arabic font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), 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
|
||||
@@ -225,6 +225,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi
|
||||
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
|
||||
[docs/control-protocol.md](docs/control-protocol.md).
|
||||
|
||||
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
|
||||
[docs/customization.md](docs/customization.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
@@ -394,8 +397,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**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))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
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
|
||||
@@ -94,6 +94,7 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
|
||||
| Portuguese | none | 0% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
|
||||
| German | partial | ≈ 100% of the catalog (989 rows) |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
@@ -118,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
|
||||
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
|
||||
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
|
||||
| 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. |
|
||||
@@ -133,6 +135,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
|
||||
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
|
||||
|
||||
@@ -275,10 +275,34 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
|
||||
eprintln!("{m}");
|
||||
}
|
||||
if cadcraft_io::is_image(o)
|
||||
&& let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i)
|
||||
{
|
||||
eprintln!("{m}");
|
||||
}
|
||||
let window = export_window(&s, framing, None)?;
|
||||
export_framed(&s, o, window)
|
||||
}
|
||||
|
||||
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
|
||||
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
|
||||
fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for e in d.model.iter() {
|
||||
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
|
||||
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
|
||||
let entities = if n == 1 { "entity" } else { "entities" };
|
||||
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
|
||||
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
@@ -666,6 +690,10 @@ fn cli() -> ExitCode {
|
||||
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some("--help" | "-h" | "help") => {
|
||||
outln!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
};
|
||||
match r {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
|
||||
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn help_flags_print_usage_to_stdout_and_exit_zero() {
|
||||
for args in [&["--help"][..], &["-h"], &["help"]] {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
|
||||
assert!(out.status.success(), "{args:?}: {:?}", out.status);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_invocation_still_reports_on_stderr() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:"));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `convert` to an image says how many model-space entities it can't draw, by type, instead of
|
||||
//! leaving them out silently (#1208). The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
|
||||
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
|
||||
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
|
||||
0\nREGION\n8\n0\n70\n1\n1\nabc\n\
|
||||
0\nREGION\n8\n0\n70\n1\n1\ndef\n\
|
||||
0\nPDFUNDERLAY\n8\n0\n10\n0\n20\n0\n41\n1\n42\n1\n43\n1\n\
|
||||
0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("proxies.dxf");
|
||||
std::fs::write(&input, PROXIES).unwrap();
|
||||
let input = input.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", input]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["counts"]["Proxy"], 3, "{v}");
|
||||
|
||||
for ext in ["svg", "png", "pdf"] {
|
||||
let image = dir.join(format!("out.{ext}"));
|
||||
let out = cli(&["convert", input, image.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("not drawn")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{ext}: {err}");
|
||||
assert_eq!(warnings[0], format!("{input}: 3 entities not drawn (PDFUNDERLAY: 1, REGION: 2)"), "{ext}");
|
||||
}
|
||||
|
||||
// A DXF copy keeps them, so there is nothing to warn about.
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", input, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// A drawing without them: no warning.
|
||||
let sample = dir.join("sample.dxf");
|
||||
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,41 +1,75 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
|
||||
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry
|
||||
/// whole drawings or images as base64 still fit.
|
||||
const MAX_LINE: usize = 256 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Resource bounds for the control server.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Limits {
|
||||
/// Connections served at once; more get an error line and are closed.
|
||||
max_clients: usize,
|
||||
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
|
||||
max_line: usize,
|
||||
/// Requests waiting for the app at once; more are answered `busy`.
|
||||
queue: usize,
|
||||
/// How long a connection may go without sending a byte before it is closed.
|
||||
idle_timeout: Duration,
|
||||
/// How long a reply write may block before the connection is closed.
|
||||
write_timeout: Duration,
|
||||
/// How long a request waits for the app's answer.
|
||||
reply_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_clients: 16,
|
||||
// Generous, so requests that carry whole drawings or images as base64 still fit.
|
||||
max_line: 256 * 1024 * 1024,
|
||||
queue: 64,
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
reply_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => {
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
serve_listener(l, Limits::default(), ctx)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
sync_channel::<ControlRequest>(0).1
|
||||
}
|
||||
};
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits));
|
||||
rx
|
||||
}
|
||||
|
||||
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
|
||||
/// its thread can't be started, or panics).
|
||||
/// A connection's place among [`Limits::max_clients`], given back when the connection ends (also
|
||||
/// when its thread can't be started, or panics).
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
@@ -44,10 +78,11 @@ impl Drop for Slot {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
@@ -57,7 +92,7 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, tx, ctx);
|
||||
serve(stream, &tx, &ctx, limits);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,7 +103,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
|
||||
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -86,48 +125,96 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// One request line, parsed.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(true) => {}
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
|
||||
Ok(false) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
|
||||
let Ok(line) = std::str::from_utf8(&buf) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
|
||||
// A line nobody asked for would be read as the reply to the client's next request.
|
||||
Err(_) if buf.is_empty() => break,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
// 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.
|
||||
pending.wait(limits.reply_timeout)
|
||||
}
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests", "state": "not-run"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
// 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);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,12 +245,26 @@ mod tests {
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
fn short() -> Limits {
|
||||
Limits {
|
||||
queue: 1,
|
||||
idle_timeout: Duration::from_millis(300),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
reply_timeout: Duration::from_millis(300),
|
||||
..Limits::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
|
||||
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
(addr, serve_listener(listener, limits, egui::Context::default()))
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let (addr, rx) = serve_with(Limits::default());
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
|
||||
@@ -172,6 +273,83 @@ mod tests {
|
||||
addr
|
||||
}
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
let mut replies = Vec::new();
|
||||
for line in BufReader::new(c).lines() {
|
||||
// A read timeout here means the server kept the connection open.
|
||||
let line = line.unwrap();
|
||||
replies.push(serde_json::from_str(&line).unwrap());
|
||||
}
|
||||
replies
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = serve_with(short());
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"cmdline.input\",\"params\":{\"text\":\"circle 0,0 5\"}}\n";
|
||||
let replies = exchange(addr, http);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let addr = serve_echo();
|
||||
let mut input = junk.to_vec();
|
||||
input.extend_from_slice(b"{\"id\":2,\"method\":\"ping\"}\n");
|
||||
let replies = exchange(addr, &input);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_connection_is_closed() {
|
||||
let (addr, _rx) = serve_with(short());
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let mut got = Vec::new();
|
||||
c.read_to_end(&mut got).unwrap();
|
||||
assert!(got.is_empty(), "{}", String::from_utf8_lossy(&got));
|
||||
assert!(t.elapsed() < Duration::from_secs(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_queue_answers_busy() {
|
||||
let (addr, rx) = serve_with(short());
|
||||
// No UI drains the one-place queue: the first request fills it, the second finds it full.
|
||||
let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n"));
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(4);
|
||||
let busy = loop {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
if v["error"].as_str().is_some_and(|e| e.starts_with("busy")) || std::time::Instant::now() > deadline {
|
||||
break v;
|
||||
}
|
||||
};
|
||||
assert_eq!((busy["id"].as_i64(), busy["state"].as_str()), (Some(2), Some("not-run")), "{busy}");
|
||||
assert_eq!(first.join().unwrap()[0]["state"], "not-run");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
let addr = serve_echo();
|
||||
@@ -191,7 +369,7 @@ mod tests {
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let held: Vec<TcpStream> = (0..Limits::default().max_clients).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
|
||||
@@ -61,3 +61,15 @@ sync_github_name = false
|
||||
real_name = "Chester Fung"
|
||||
display_name = "Chester Fung"
|
||||
sync_github_name = true
|
||||
|
||||
[people.C00kieMonsta]
|
||||
real_name = "Antoine Boxho"
|
||||
display_name = "Antoine Boxho"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.gurparvez]
|
||||
real_name = "Gurparvez"
|
||||
@@ -22,7 +22,7 @@ impl Rgb {
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
|
||||
@@ -225,6 +225,14 @@ pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_non_hex_digits() {
|
||||
assert_eq!(Rgb::parse_hex("+12345"), None);
|
||||
assert_eq!(Rgb::parse_hex("-12345"), None);
|
||||
assert_eq!(Color::parse("#+12345"), None);
|
||||
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_known_entries() {
|
||||
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
|
||||
|
||||
@@ -185,6 +185,43 @@ pub fn names(e: &Expr, out: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with every whole-name use of `old` replaced by `new`; numbers (`1e3`), other names
|
||||
/// (`d10` for `d1`) and spacing are left as written.
|
||||
pub fn rename(text: &str, old: &str, new: &str) -> String {
|
||||
let b = text.as_bytes();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let start = i;
|
||||
if c.is_ascii_digit() || c == b'.' {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
|
||||
i += 1;
|
||||
}
|
||||
if b.get(i).is_some_and(|c| *c == b'e' || *c == b'E') {
|
||||
let mut j = i + 1;
|
||||
if b.get(j).is_some_and(|c| *c == b'+' || *c == b'-') {
|
||||
j += 1;
|
||||
}
|
||||
if b.get(j).is_some_and(u8::is_ascii_digit) {
|
||||
i = j;
|
||||
while b.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if c.is_ascii_alphabetic() || c == b'_' {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
i += text.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
|
||||
}
|
||||
let token = text.get(start..i).unwrap_or_default();
|
||||
out.push_str(if token == old { new } else { token });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluate with a variable lookup.
|
||||
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
|
||||
let v = match e {
|
||||
@@ -314,6 +351,13 @@ mod tests {
|
||||
assert!(chain.value("p0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
assert_eq!(rename("1e2+e", "e", "x"), "1e2+x");
|
||||
assert_eq!(rename("é+a", "a", "b"), "é+b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_valid() {
|
||||
assert!(valid_name("d1") && valid_name("_w") && !valid_name("1d") && !valid_name("a-b") && !valid_name("") && !valid_name("pi"));
|
||||
|
||||
@@ -428,6 +428,46 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put back what each Fix locks where `before` had it, and report whether anything moved.
|
||||
///
|
||||
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
|
||||
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
|
||||
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
|
||||
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let refs: Vec<GeomRef> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.flat_map(|c| c.refs.iter().copied())
|
||||
.collect();
|
||||
if refs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let handles: Vec<Handle> = refs.iter().map(|r| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let mut x = now.x0.clone();
|
||||
let mut changed = false;
|
||||
for r in &refs {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
|
||||
if !same_shape {
|
||||
continue;
|
||||
}
|
||||
for (i, j) in i.into_iter().zip(j) {
|
||||
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
|
||||
&& *slot != v
|
||||
{
|
||||
*slot = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
now.write_back(d, &x);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// True when every interpretable constraint holds at the current geometry.
|
||||
pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
@@ -659,6 +699,33 @@ pub fn set_parameter(d: &mut Drawing, name: &str, expr_text: &str, opts: &SolveO
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Rename a parameter (dimensional constraint name or user parameter) and every expression that
|
||||
/// uses it. Geometry doesn't change. On error the drawing is unchanged.
|
||||
pub fn rename_parameter(d: &mut Drawing, old: &str, new: &str) -> Result<(), SolveError> {
|
||||
if !name_in_use(d, old) {
|
||||
return Err(SolveError::Other(format!("no parameter `{old}`")));
|
||||
}
|
||||
if !expr::valid_name(new) {
|
||||
return Err(SolveError::Other(format!("invalid parameter name `{new}`")));
|
||||
}
|
||||
if new != old && name_in_use(d, new) {
|
||||
return Err(SolveError::Other(format!("`{new}` is already in use")));
|
||||
}
|
||||
for c in d.constraints.iter_mut().filter(|c| c.kind.is_dimensional()) {
|
||||
if c.name == old {
|
||||
c.name = new.to_string();
|
||||
}
|
||||
c.expr = expr::rename(&c.expr, old, new);
|
||||
}
|
||||
for p in &mut d.parametric.parameters {
|
||||
if p.name == old {
|
||||
p.name = new.to_string();
|
||||
}
|
||||
p.expr = expr::rename(&p.expr, old, new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where to draw a constraint's glyphs (one anchor per reference).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -40,6 +40,11 @@ pub(crate) enum Form {
|
||||
C1,
|
||||
/// Arc sweep (angular on an arc).
|
||||
ArcSweep,
|
||||
/// Two lines measured along the rays from their intersection to each line's far end, the
|
||||
/// angle the dimension draws; `rev` marks a line whose far end is its start point.
|
||||
Rays {
|
||||
rev: [bool; 2],
|
||||
},
|
||||
/// Fix of a derived point (residual to an anchor).
|
||||
Anchor(Vec2),
|
||||
/// Fix handled by locking parameters (no rows).
|
||||
@@ -92,6 +97,10 @@ fn dir_n(a: Vec2, b: Vec2) -> Vec2 {
|
||||
if l > 1e-12 { d * (1.0 / l) } else { d }
|
||||
}
|
||||
|
||||
fn ray(a: Vec2, b: Vec2, rev: bool) -> Vec2 {
|
||||
if rev { dir_n(b, a) } else { dir_n(a, b) }
|
||||
}
|
||||
|
||||
fn sgn(v: f64) -> f64 {
|
||||
if v < 0.0 { -1.0 } else { 1.0 }
|
||||
}
|
||||
@@ -284,10 +293,15 @@ impl Built {
|
||||
}
|
||||
K::Angular => {
|
||||
b.rows = 1;
|
||||
if n == 2 && ln(0).is_some() && ln(1).is_some() {
|
||||
b.form = Form::LL;
|
||||
let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") };
|
||||
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
|
||||
if let (2, Some((a1, b1)), Some((a2, b2))) = (n, ln(0), ln(1)) {
|
||||
// Start-to-end directions would measure the supplement when a line is drawn
|
||||
// towards the vertex, so use the rays the dimension draws.
|
||||
let rev = match cadcraft_geom::line_line_infinite(a1, b1, a2, b2) {
|
||||
Some((_, t, u)) => [t > 0.5, u > 0.5],
|
||||
None => [false, false],
|
||||
};
|
||||
b.form = Form::Rays { rev };
|
||||
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
|
||||
b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2)));
|
||||
} else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() {
|
||||
b.form = Form::ArcSweep;
|
||||
@@ -297,7 +311,7 @@ impl Built {
|
||||
b.target = target.to_radians();
|
||||
// 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 };
|
||||
let max = if matches!(b.form, Form::Rays { .. }) { 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());
|
||||
}
|
||||
@@ -461,9 +475,9 @@ impl Built {
|
||||
(K::Angular, Form::ArcSweep) => {
|
||||
out.push(m.arc_sweep(x, r(0)?)? - self.target);
|
||||
}
|
||||
(K::Angular, _) => {
|
||||
(K::Angular, Form::Rays { rev }) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
|
||||
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
|
||||
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
|
||||
}
|
||||
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use super::{DynamicBlock, ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most grips listed for one reference.
|
||||
@@ -79,6 +79,29 @@ pub struct DynGrip {
|
||||
pub choices: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which ends of two-point parameter `node` the block's actions follow, as (base, end): a
|
||||
/// connection to one point's change (BaseXDelta, EndYDelta, UpdatedEnd…) follows that end, one to
|
||||
/// the value itself (a distance, an angle) follows both. None when no action is attached to the
|
||||
/// parameter: its grips are all kept, as the block has them.
|
||||
fn ends_used(m: &DynamicBlock, node: i32) -> Option<(bool, bool)> {
|
||||
let mut used: Option<(bool, bool)> = None;
|
||||
for (n, name) in m.actions.iter().flat_map(|a| a.connections.iter()) {
|
||||
if *n != node {
|
||||
continue;
|
||||
}
|
||||
let c = name.to_ascii_lowercase();
|
||||
let (base, end) = used.unwrap_or((false, false));
|
||||
used = Some(if c.starts_with("base") || c.starts_with("updatedbase") {
|
||||
(true, end)
|
||||
} else if c.starts_with("end") || c.starts_with("updatedend") {
|
||||
(base, true)
|
||||
} else {
|
||||
(true, true)
|
||||
});
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
fn point<S: serde::Serializer>(p: &Vec2, s: S) -> Result<S::Ok, S::Error> {
|
||||
[p.x, p.y].serialize(s)
|
||||
}
|
||||
@@ -112,8 +135,15 @@ pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A grip shows in the current visibility state when the state lists it, or lists its
|
||||
// parameter (AutoCAD shows a listed parameter's grips though the grips aren't listed).
|
||||
let param_handle: HashMap<i32, Handle> = m.parameters.iter().map(|p| (p.node, p.handle)).collect();
|
||||
let shown = |node: i32, id: i32| {
|
||||
id != 0 && (Some(node) == visibility_node || shown_in_state.as_ref().is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h))))
|
||||
id != 0
|
||||
&& (Some(node) == visibility_node
|
||||
|| shown_in_state
|
||||
.as_ref()
|
||||
.is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h)) || param_handle.get(&node).is_some_and(|h| a.contains(h))))
|
||||
};
|
||||
let choices: HashMap<String, Vec<String>> =
|
||||
custom_properties(d, insert).map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.choices)).collect()).unwrap_or_default();
|
||||
@@ -121,15 +151,21 @@ pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
for p in &m.parameters {
|
||||
let grip = |i: usize| p.grips.get(i).copied().unwrap_or(0);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base, end, label, .. } => {
|
||||
ParameterKind::Linear { base, end, label, from_midpoint, .. } => {
|
||||
let (b, e) = match r.values.get(&p.node) {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
let u = e - b;
|
||||
let u = if u.len() > 1e-12 { u * (1.0 / u.len()) } else { Vec3::new(1.0, 0.0, 0.0) };
|
||||
// An end no action follows has no grip (dragging it would change nothing); a
|
||||
// distance from its midpoint moves both ends.
|
||||
let used = match ends_used(&m, p.node) {
|
||||
Some((base, end)) if !*from_midpoint => [base, end],
|
||||
_ => [true, true],
|
||||
};
|
||||
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
|
||||
if shown(p.node, grip(i)) {
|
||||
if used[i] && shown(p.node, grip(i)) {
|
||||
let (at, dir) = (world(at), toward(at, way));
|
||||
out.push(DynGrip { node: p.node, part, shape: GripShape::Linear, at, dir, property: label.clone(), choices: Vec::new() });
|
||||
}
|
||||
|
||||
@@ -142,12 +142,14 @@ impl Drawing {
|
||||
d.header = Header::imperial();
|
||||
d
|
||||
}
|
||||
/// An empty metric drawing (millimetres, ISO-25 dimensions).
|
||||
/// An empty metric drawing (millimetres, ISO-25 dimensions, multileaders and tables to match).
|
||||
pub fn new_metric() -> Self {
|
||||
let mut d = Drawing::bare();
|
||||
d.header = Header::metric();
|
||||
d.scales = metric_scales();
|
||||
d.dim_styles.push(DimStyle::iso25());
|
||||
d.mleader_styles = vec![MLeaderStyle::metric()];
|
||||
d.table_styles = vec![TableStyle::metric()];
|
||||
d.header.set_str("DIMSTYLE", "ISO-25");
|
||||
d
|
||||
}
|
||||
|
||||
@@ -616,6 +616,13 @@ impl Default for MLeaderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
impl MLeaderStyle {
|
||||
/// The Standard style for metric drawings: text and arrows as high as ISO-25's.
|
||||
pub fn metric() -> Self {
|
||||
MLeaderStyle { arrow_size: 2.5, text_height: 2.5, landing_gap: 0.625, dogleg: 5.0, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableStyle {
|
||||
@@ -636,6 +643,13 @@ impl Default for TableStyle {
|
||||
}
|
||||
}
|
||||
|
||||
impl TableStyle {
|
||||
/// The Standard style for metric drawings: text as high as ISO-25's.
|
||||
pub fn metric() -> Self {
|
||||
TableStyle { text_height: 2.5, margin: 1.0, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
mod cellstyle;
|
||||
mod tablebreak;
|
||||
pub use cellstyle::*;
|
||||
@@ -750,6 +764,9 @@ pub struct PageSetup {
|
||||
/// Plot objects with their transparency ("Plot transparency"; PLOTTRANSPARENCYOVERRIDE can
|
||||
/// override it). The DXF plot layout flags have no bit for it, so it is not saved in DXF.
|
||||
pub plot_transparency: bool,
|
||||
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
|
||||
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
|
||||
pub paper_mm: bool,
|
||||
}
|
||||
|
||||
impl Default for PageSetup {
|
||||
@@ -769,6 +786,7 @@ impl Default for PageSetup {
|
||||
lineweights: true,
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
paper_mm: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,23 @@ fn new_drawing_has_standard_tables() {
|
||||
assert!(m.dim_style("ISO-25").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_metric_drawing_has_metric_multileader_and_table_styles() {
|
||||
let m = Drawing::new_metric();
|
||||
let iso = m.dim_style("ISO-25").unwrap();
|
||||
let ml = m.mleader_styles.iter().find(|s| s.name == "Standard").unwrap();
|
||||
assert_eq!((ml.text_height, ml.arrow_size), (iso.text_height, iso.arrow_size));
|
||||
assert_eq!(ml.dogleg, 5.0);
|
||||
let ts = m.table_styles.iter().find(|s| s.name == "Standard").unwrap();
|
||||
assert_eq!(ts.text_height, iso.text_height);
|
||||
assert_eq!(iso.text_height, 2.5);
|
||||
|
||||
let i = Drawing::new_imperial();
|
||||
assert_eq!(i.mleader_styles, vec![MLeaderStyle::default()]);
|
||||
assert_eq!(i.table_styles, vec![TableStyle::default()]);
|
||||
assert_eq!((i.mleader_styles[0].text_height, i.table_styles[0].text_height), (0.18, 0.18));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_extents() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
|
||||
+337
-2
@@ -83,10 +83,17 @@ mod native {
|
||||
let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string());
|
||||
return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string())));
|
||||
}
|
||||
let notes = read_notes(&outcome.stats);
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
current_style_names(&mut doc);
|
||||
table_merges(&mut doc);
|
||||
table_style_rows(&mut doc, false);
|
||||
dimension_flags(&mut doc, false);
|
||||
let orphans = drop_orphaned_dgn_components(&mut doc);
|
||||
if orphans > 0 {
|
||||
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
|
||||
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
|
||||
}
|
||||
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
|
||||
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
|
||||
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
|
||||
@@ -265,6 +272,83 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// The DWG dimension flags byte. Its bit 0 is the opposite of DXF group 70's bit 128 (set: the
|
||||
/// text is at its default position) and its bit 1 is group 70's bit 32 (ODA DWG
|
||||
/// specification, common dimension data), while acadrust 0.6.4 reads and writes bit 0 as
|
||||
/// `text_user_positioned` itself and keeps no bit 32 from DXF. Without this, every dimension
|
||||
/// of a DWG CADCraft writes is user-positioned for AutoCAD (#1094), and every dimension of an
|
||||
/// AutoCAD DWG is read as user-positioned. `to_dwg` sets the fields up for the writer:
|
||||
/// bit 32 as CADCraft's DXF writer always sets it, and bit 0 through the inverted field;
|
||||
/// otherwise the field is read from bit 0 after a DWG is read.
|
||||
fn dimension_flags(doc: &mut acadrust::CadDocument, to_dwg: bool) {
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
|
||||
let b = d.base_mut();
|
||||
if to_dwg {
|
||||
b.dwg_flags_byte |= 0x02;
|
||||
b.text_user_positioned = !b.text_user_positioned;
|
||||
} else {
|
||||
b.text_user_positioned = b.dwg_flags_byte & 0x01 == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave out DGN line style components (`LS*COMPONENT`) that no line style definition uses
|
||||
/// (#1482). A drawing that once came from MicroStation can carry hundreds of thousands of
|
||||
/// them, orphaned copies piled up by earlier DGN imports and copy-pastes (AutoCAD's PURGE
|
||||
/// removes them as orphaned data): in a real 83 MB site plan they were 888,483 of 889,053
|
||||
/// components and made its DXF rendition 1.2 GB, over [`Limits::DEFAULT`], so the file
|
||||
/// didn't open at all. No definition that was read refers to them (a definition the reader
|
||||
/// skipped can't use them either) and CADCraft doesn't draw DGN line styles, so nothing
|
||||
/// visible is lost.
|
||||
/// Every component a definition reaches (directly, or through compound and point
|
||||
/// components) is kept, as are the definitions; dictionary entries that named a dropped
|
||||
/// component go with it. Returns how many were left out.
|
||||
fn drop_orphaned_dgn_components(doc: &mut acadrust::CadDocument) -> usize {
|
||||
use acadrust::objects::{DgnLineStyleData, ObjectType};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// Each component's references, and the definitions' root components to start from.
|
||||
let mut refs: HashMap<acadrust::Handle, Vec<acadrust::Handle>> = HashMap::new();
|
||||
let mut stack: Vec<acadrust::Handle> = Vec::new();
|
||||
for (h, o) in &doc.objects {
|
||||
let ObjectType::DgnLineStyle(ls) = o else { continue };
|
||||
match &ls.data {
|
||||
DgnLineStyleData::Definition { root_component, .. } => stack.push(*root_component),
|
||||
DgnLineStyleData::Component { component, .. } => {
|
||||
refs.insert(*h, component.references());
|
||||
}
|
||||
DgnLineStyleData::Registered { .. } => {}
|
||||
}
|
||||
}
|
||||
if refs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Each component is visited once, so cycles and shared subtrees end.
|
||||
let mut used: HashSet<acadrust::Handle> = HashSet::new();
|
||||
while let Some(h) = stack.pop() {
|
||||
if let Some(r) = refs.get(&h)
|
||||
&& used.insert(h)
|
||||
{
|
||||
stack.extend(r.iter().copied());
|
||||
}
|
||||
}
|
||||
let orphans: HashSet<acadrust::Handle> = refs.into_keys().filter(|h| !used.contains(h)).collect();
|
||||
if orphans.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
doc.objects.retain(|h, _| !orphans.contains(h));
|
||||
doc.dgn_ls_components.retain(|h, _| !orphans.contains(h));
|
||||
for o in doc.objects.values_mut() {
|
||||
let ObjectType::Dictionary(d) = o else { continue };
|
||||
if d.entries.iter().any(|(_, e)| orphans.contains(e)) {
|
||||
d.entries.retain(|(_, e)| !orphans.contains(e));
|
||||
let keys: HashSet<&str> = d.entries.iter().map(|(k, _)| k.as_str()).collect();
|
||||
d.hard_owner_entries.retain(|k| keys.contains(k.as_str()));
|
||||
}
|
||||
}
|
||||
orphans.len()
|
||||
}
|
||||
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
@@ -307,7 +391,8 @@ mod native {
|
||||
/// 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()),
|
||||
// DWG holds radians; `$ANGBASE` is in degrees.
|
||||
("$ANGBASE", 50, h.angle_base.to_degrees().to_string()),
|
||||
("$ANGDIR", 70, h.angle_direction.to_string()),
|
||||
("$FILLETRAD", 40, h.fillet_radius.to_string()),
|
||||
("$CHAMFERA", 40, h.chamfer_distance_a.to_string()),
|
||||
@@ -385,6 +470,30 @@ mod native {
|
||||
convert(dxf, Some(v))
|
||||
}
|
||||
|
||||
/// The current dimension and text styles' names from their handles: a DWG stores the
|
||||
/// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG
|
||||
/// lost its current dimension style (#1051).
|
||||
fn current_style_names(doc: &mut acadrust::CadDocument) {
|
||||
let h = doc.header.current_dimstyle_handle;
|
||||
if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) {
|
||||
doc.header.current_dimstyle_name = n;
|
||||
}
|
||||
let h = doc.header.current_text_style_handle;
|
||||
if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) {
|
||||
doc.header.current_text_style_name = n;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the
|
||||
/// handle read with it needn't be the style's in the document, in which case the DWG writer
|
||||
/// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051).
|
||||
fn current_dimstyle(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_dimstyle_name.clone();
|
||||
if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) {
|
||||
doc.header.current_dimstyle_handle = h;
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
let data = dxf.to_vec();
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
@@ -398,12 +507,40 @@ mod native {
|
||||
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
|
||||
None => {}
|
||||
}
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc, true);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
}
|
||||
|
||||
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
|
||||
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
|
||||
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
|
||||
/// second layout landed on the first one, and the second stayed empty (#1057).
|
||||
fn other_layout_owners(doc: &mut acadrust::CadDocument) {
|
||||
let others: std::collections::HashSet<acadrust::Handle> = doc
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| {
|
||||
let n = br.name.to_ascii_uppercase();
|
||||
n.len() > 12 && n.starts_with("*PAPER_SPACE") && n[12..].bytes().all(|b| b.is_ascii_digit())
|
||||
})
|
||||
.map(|br| br.handle)
|
||||
.collect();
|
||||
if others.is_empty() {
|
||||
return;
|
||||
}
|
||||
for e in doc.entities_mut() {
|
||||
let c = e.common_mut();
|
||||
if others.contains(&c.owner_handle) {
|
||||
c.entity_mode = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -453,4 +590,202 @@ mod tests {
|
||||
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
|
||||
assert!(dwg_to_dxf(b"").is_err());
|
||||
}
|
||||
|
||||
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
|
||||
let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n");
|
||||
for (text, flags, sub, pts) in dims {
|
||||
s += " 0\nDIMENSION\n 8\n0\n100\nAcDbEntity\n100\nAcDbDimension\n 10\n0\n 20\n0\n 30\n0\n";
|
||||
s += &format!(" 11\n50\n 21\n-20\n 31\n0\n 70\n{flags}\n 1\n{text}\n 3\nStandard\n100\n{sub}\n");
|
||||
for (code, [x, y]) in pts.iter() {
|
||||
s += &format!("{code:>3}\n{x}\n{:>3}\n{y}\n{:>3}\n0\n", code + 10, code + 20);
|
||||
}
|
||||
}
|
||||
s + " 0\nENDSEC\n 0\nEOF\n"
|
||||
}
|
||||
|
||||
/// DWG stores DXF group 70's bit 128 inverted, in bit 0 of the dimension flags byte (set:
|
||||
/// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common
|
||||
/// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way
|
||||
/// as the writer can't hide a wrong byte.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() {
|
||||
let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])];
|
||||
let radius: &[(i32, [f64; 2])] = &[(15, [10.0, 0.0])];
|
||||
let angular: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [10.0, 0.0]), (15, [0.0, 0.0]), (16, [0.0, 10.0])];
|
||||
let kinds = [
|
||||
(0, "AcDbAlignedDimension", linear),
|
||||
(1, "AcDbAlignedDimension", linear),
|
||||
(4, "AcDbRadialDimension", radius),
|
||||
(2, "AcDb2LineAngularDimension", angular),
|
||||
];
|
||||
let mut dims = Vec::new();
|
||||
for (ty, sub, pts) in kinds {
|
||||
dims.push(("HOME", ty | 32, sub, pts));
|
||||
dims.push(("MOVED", ty | 32 | 128, sub, pts));
|
||||
}
|
||||
let dwg = dxf_to_dwg(dims_dxf(&dims).as_bytes()).unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for e in doc.entities() {
|
||||
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
|
||||
let (text, byte) = (d.base().text_override().unwrap_or_default(), d.base().dwg_flags_byte);
|
||||
seen.push((text.to_string(), byte & 0x03));
|
||||
assert_eq!(byte & 0x02, 0x02, "{text} {:?}: bit 32 of group 70 is lost (flags byte {byte:#04x})", d.base().dimension_type);
|
||||
let default_pos = text == "HOME";
|
||||
assert_eq!(byte & 0x01 != 0, default_pos, "{text} {:?}: flags byte {byte:#04x}", d.base().dimension_type);
|
||||
}
|
||||
for text in ["HOME", "MOVED"] {
|
||||
assert_eq!(seen.iter().filter(|(t, _)| t == text).count(), 4, "{seen:?}");
|
||||
}
|
||||
|
||||
// And CADCraft reads its own DWG back as it wrote it: 128 only on the moved texts.
|
||||
let dxf = String::from_utf8(dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||||
let mut flags = Vec::new();
|
||||
for rec in lines.chunks(2).collect::<Vec<_>>().split(|p| p.first() == Some(&"0")) {
|
||||
let value = |code: &str| rec.iter().find(|p| p.first() == Some(&code)).and_then(|p| p.get(1).copied());
|
||||
if let (Some(text @ ("HOME" | "MOVED")), Some(v)) = (value("1"), value("70")) {
|
||||
flags.push((text, v.parse::<i64>().unwrap_or(-1) & 128));
|
||||
}
|
||||
}
|
||||
assert_eq!(flags.len(), 8, "{flags:?}");
|
||||
assert!(flags.iter().all(|(l, f)| (*f == 128) == (*l == "MOVED")), "{flags:?}");
|
||||
}
|
||||
|
||||
/// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dxf_handles(dxf: &str) -> std::collections::HashSet<u64> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
lines
|
||||
.chunks(2)
|
||||
.filter_map(|p| match p {
|
||||
[c, v] if matches!(c.trim().parse::<i32>(), Ok(5 | 105 | 330..=369)) => u64::from_str_radix(v.trim(), 16).ok(),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Issue #1482: a DWG that once came from MicroStation can carry hundreds of thousands of DGN
|
||||
/// line style components no definition uses (a real 83 MB site plan: 888,483 of 889,053, a
|
||||
/// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and
|
||||
/// their dictionary entries with them; the definition and every component it reaches —
|
||||
/// through compound and point components, around a cycle — are kept.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn orphaned_dgn_line_style_components_are_left_out() {
|
||||
use acadrust::objects::*;
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
let container = doc.allocate_handle();
|
||||
let nod = doc.header.named_objects_dict_handle;
|
||||
let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&nod) else { panic!("no named object dictionary") };
|
||||
root.entries.push(("ACAD_DGNLINESTYLECOMP".into(), container));
|
||||
let pattern = || DgnLsStrokePattern {
|
||||
has_iteration_limit: false,
|
||||
is_single_segment: false,
|
||||
iteration_limit: 0,
|
||||
auto_phase: 0.0,
|
||||
phase: 0.0,
|
||||
phase_mode: DgnLsPhaseMode::Distance,
|
||||
strokes: vec![DgnLsStroke {
|
||||
is_dash: true,
|
||||
bypass_corner: false,
|
||||
can_be_scaled: true,
|
||||
invert_at_origin: false,
|
||||
invert_at_end: false,
|
||||
length: 2.5,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
width_mode: 0,
|
||||
cap_mode: 0,
|
||||
}],
|
||||
};
|
||||
let kind = |c: &DgnLsComponentData| match c {
|
||||
DgnLsComponentData::Symbol(_) => DgnLsComponentType::Symbol,
|
||||
DgnLsComponentData::Compound(_) => DgnLsComponentType::Compound,
|
||||
DgnLsComponentData::Stroke(_) => DgnLsComponentType::Stroke,
|
||||
DgnLsComponentData::Point(_) => DgnLsComponentType::Point,
|
||||
DgnLsComponentData::Internal(_) => DgnLsComponentType::Internal,
|
||||
};
|
||||
let compound = |of: &[acadrust::Handle]| {
|
||||
DgnLsComponentData::Compound(DgnLsCompoundComponent {
|
||||
entries: of.iter().map(|&component| DgnLsCompoundEntry { component, offset: 0.0 }).collect(),
|
||||
})
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut put = |doc: &mut acadrust::CadDocument, h: acadrust::Handle, data: DgnLineStyleData| {
|
||||
entries.push((format!("{:X}", h.value()), h));
|
||||
let reactors = vec![container];
|
||||
doc.objects
|
||||
.insert(h, ObjectType::DgnLineStyle(DgnLineStyleObject { handle: h, owner: container, reactors, xdictionary_handle: None, data }));
|
||||
};
|
||||
let component = |c: DgnLsComponentData| DgnLineStyleData::Component {
|
||||
kind: kind(&c),
|
||||
description: "dash".into(),
|
||||
version: 1,
|
||||
component_uid: [7; 16],
|
||||
scale: 1.0,
|
||||
property_flags: 0,
|
||||
component: c,
|
||||
properties: vec![],
|
||||
};
|
||||
let h: Vec<acadrust::Handle> = (0..9).map(|_| doc.allocate_handle()).collect();
|
||||
let [def, root, stroke, point, back, orphan, orphan_cycle, internal, shared] = h[..] else { panic!("nine handles") };
|
||||
put(
|
||||
&mut doc,
|
||||
def,
|
||||
DgnLineStyleData::Definition {
|
||||
description: "{ dash }".into(),
|
||||
version: 1,
|
||||
style_number: 1,
|
||||
component_uid: [1; 16],
|
||||
is_continuous: false,
|
||||
unit_definition: 1.0,
|
||||
unit_scale: 1.0,
|
||||
units_type: 0,
|
||||
is_element: false,
|
||||
is_physical: false,
|
||||
is_scale_independent: false,
|
||||
is_snappable: false,
|
||||
root_component: root,
|
||||
properties: vec![],
|
||||
},
|
||||
);
|
||||
// Used: def → root → {stroke, point → shared, back → root (a cycle)}.
|
||||
put(&mut doc, root, component(compound(&[stroke, point, back])));
|
||||
put(&mut doc, stroke, component(DgnLsComponentData::Stroke(pattern())));
|
||||
put(&mut doc, point, component(DgnLsComponentData::Point(DgnLsPointComponent { stroke_component: shared, symbols: vec![] })));
|
||||
put(&mut doc, back, component(compound(&[root])));
|
||||
put(&mut doc, shared, component(DgnLsComponentData::Stroke(pattern())));
|
||||
// Orphans: an internal component, and two compounds that refer to each other and to a
|
||||
// used component (which stays).
|
||||
let pattern_internal =
|
||||
DgnLsInternalComponent { pattern: pattern(), internal_version: 0, hardware_style: 0, is_hardware_style: false, line_code: 0 };
|
||||
put(&mut doc, internal, component(DgnLsComponentData::Internal(pattern_internal)));
|
||||
put(&mut doc, orphan, component(compound(&[orphan_cycle, shared])));
|
||||
put(&mut doc, orphan_cycle, component(compound(&[orphan])));
|
||||
let mut dict = Dictionary::new();
|
||||
dict.handle = container;
|
||||
dict.owner = nod;
|
||||
dict.entries = entries;
|
||||
doc.objects.insert(container, ObjectType::Dictionary(dict));
|
||||
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
|
||||
|
||||
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
|
||||
let dxf = String::from_utf8(dxf).unwrap();
|
||||
let handles = dxf_handles(&dxf);
|
||||
for kept in [def, root, stroke, point, back, shared] {
|
||||
assert!(handles.contains(&kept.value()), "{:X} was left out", kept.value());
|
||||
}
|
||||
for gone in [internal, orphan, orphan_cycle] {
|
||||
assert!(!handles.contains(&gone.value()), "{:X} was kept or is still referred to", gone.value());
|
||||
}
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let ls: Vec<&str> = lines.chunks(2).filter_map(|p| p.get(1).filter(|_| p[0].trim() == "0" && p[1].starts_with("LS")).copied()).collect();
|
||||
assert_eq!(ls.iter().filter(|t| t.ends_with("COMPONENT")).count(), 5, "{ls:?}");
|
||||
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
|
||||
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ cadcraft-dxf = { workspace = true }
|
||||
cadcraft-constraints = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
craft-docpath = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -82,6 +82,12 @@ impl Report {
|
||||
"message": self.message(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Format this report for an AUDITCTL `.adt` log file.
|
||||
pub fn format_adt(&self, title: &str, path: Option<&str>) -> String {
|
||||
let name = path.unwrap_or(title);
|
||||
format!("Audit of drawing: {name}\n\n{}\n", self.message())
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit `d` without changing it.
|
||||
|
||||
@@ -260,18 +260,16 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
};
|
||||
let k = match at {
|
||||
Some(at) => text_inside(k, c, c.dist(pt), at),
|
||||
Some(at) => text_at(k, at),
|
||||
None => k,
|
||||
};
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
/// A radius/diameter dimension line location picked inside the circle puts the text there, on
|
||||
/// the dimension line (outside, the text keeps its default place beyond the arrowhead).
|
||||
fn text_inside(mut k: EntityKind, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||||
if let EntityKind::Dimension(d) = &mut k
|
||||
&& at.dist(c) < r
|
||||
{
|
||||
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
|
||||
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
|
||||
fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
|
||||
if let EntityKind::Dimension(d) = &mut k {
|
||||
d.text_mid = v3(at);
|
||||
d.user_text_pos = true;
|
||||
}
|
||||
@@ -1106,7 +1104,7 @@ impl RadialM {
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
});
|
||||
text_inside(k, c, r, at)
|
||||
text_at(k, at)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -626,7 +626,7 @@ impl Interactive for RectM {
|
||||
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),
|
||||
Some(c) => Prompt::new("Specify other corner", Accept::POINT).corner(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),
|
||||
|
||||
@@ -84,7 +84,11 @@ fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if fix && !issues.is_empty() {
|
||||
s.state_mut()?.doc = Arc::new(copy);
|
||||
}
|
||||
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
|
||||
let report = crate::audit::Report { issues, fixed: fix };
|
||||
if s.settings.auditctl {
|
||||
write_audit_log(s, &report);
|
||||
}
|
||||
Ok(report.to_json())
|
||||
}
|
||||
|
||||
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
|
||||
@@ -119,12 +123,33 @@ fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
}
|
||||
let uid = st.uid;
|
||||
if s.settings.auditctl {
|
||||
write_audit_log(s, &report);
|
||||
}
|
||||
let mut r = report.to_json();
|
||||
r["index"] = json!(i);
|
||||
r["uid"] = json!(uid);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_audit_log(s: &Session, report: &crate::audit::Report) {
|
||||
let (path_str, title) = match s.state() {
|
||||
Ok(st) => (st.path.clone(), st.title.clone()),
|
||||
Err(_) => (None, "Drawing".to_string()),
|
||||
};
|
||||
// An unsaved drawing has no folder of its own to hold the log.
|
||||
let Some(path) = &path_str else { return };
|
||||
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
|
||||
let content = report.format_adt(&title, path_str.as_deref());
|
||||
if let Err(e) = std::fs::write(&adt_path, content) {
|
||||
log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[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}")))
|
||||
|
||||
@@ -16,6 +16,7 @@ use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
mod burst;
|
||||
mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
@@ -72,6 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
battman::battman_spec(),
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
@@ -108,8 +110,41 @@ fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> b
|
||||
false
|
||||
}
|
||||
|
||||
/// What BLOCK does with the selected objects once the block is defined (the dialog's Convert to
|
||||
/// block, Retain and Delete).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Keep {
|
||||
/// Replace the objects by a reference to the new block.
|
||||
Convert,
|
||||
/// Leave the objects in the drawing.
|
||||
Retain,
|
||||
/// Erase the objects.
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl Keep {
|
||||
/// The `keep` parameter's documented values, in any case; `None` for anything else.
|
||||
pub(crate) fn parse(value: &str) -> Option<Keep> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"convert" => Some(Keep::Convert),
|
||||
"retain" => Some(Keep::Retain),
|
||||
"delete" => Some(Keep::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `keep` parameter of `cmd`, `default` when absent. A value that is not documented is an
|
||||
/// error (issue #935: a misspelt value used to erase the objects without converting them).
|
||||
fn keep_param(cmd: &str, p: &Value, default: Keep) -> Result<Keep> {
|
||||
match str_param(p, "keep") {
|
||||
None => Ok(default),
|
||||
Some(v) => Keep::parse(v).ok_or_else(|| bad(cmd, format!("`keep` must be \"convert\", \"retain\" or \"delete\", not {v:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: Keep, description: &str) -> Result<Option<Handle>> {
|
||||
if !valid_block_name(name) {
|
||||
return Err(bad("block", "invalid block name"));
|
||||
}
|
||||
@@ -136,13 +171,13 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
b.entities.push(e);
|
||||
}
|
||||
doc.blocks.insert(name.to_string(), Arc::new(b));
|
||||
if keep != "retain" {
|
||||
if keep != Keep::Retain {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
if keep == "convert" {
|
||||
if keep == Keep::Convert {
|
||||
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
|
||||
return Ok(Some(h));
|
||||
}
|
||||
@@ -219,8 +254,8 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string();
|
||||
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
||||
let hs = targets(s, p)?;
|
||||
let keep = str_param(p, "keep").unwrap_or("convert").to_string();
|
||||
let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?;
|
||||
let keep = keep_param("block", p, Keep::Convert)?;
|
||||
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
|
||||
// An annotative block: its references (the converted selection too) are annotative.
|
||||
if bool_or(p, "annotative", false) {
|
||||
let d = s.doc_mut()?;
|
||||
@@ -495,7 +530,7 @@ 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) };
|
||||
// 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" };
|
||||
let keep = if self.dash { Keep::Delete } else { Keep::Convert };
|
||||
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
|
||||
s.echo(e.to_string());
|
||||
return Ok(Step::Done);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! BURST (Express Tools): explode block references as EXPLODE does, but their visible attributes
|
||||
//! become text with the values they show instead of the block's attribute definitions.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Insert};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::machines::SelectRun;
|
||||
use crate::cmd::modify::{explode_kind, explode_with};
|
||||
use crate::cmd::{CommandSpec, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(super) fn burst_spec() -> CommandSpec {
|
||||
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
|
||||
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
|
||||
.params("{handles?} (block references; other objects are left alone) → {handles}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
|
||||
}
|
||||
|
||||
fn run_burst(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let r = explode_with(s, &hs, burst_pieces)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// What a block reference bursts into: EXPLODE's pieces without the attribute definitions, then
|
||||
/// a text (multiline text for a multiline attribute) for each visible attribute.
|
||||
fn burst_pieces(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let mut out: Vec<Entity> = explode_kind(d, e)?.into_iter().filter(|p| !matches!(p.kind, EntityKind::AttDef(_))).collect();
|
||||
for a in ins.attribs.iter().filter(|a| !a.invisible) {
|
||||
let kind = match a.multiline_text() {
|
||||
Some(m) => EntityKind::MText(m),
|
||||
None => EntityKind::Text(a.text.clone()),
|
||||
};
|
||||
out.push(Entity { handle: Handle(0), common: attrib_common(d, e, ins, &a.tag, &a.props), kind });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The properties an attribute is drawn with: its own, else its definition's; layer 0 is the
|
||||
/// reference's layer and ByBlock colour the reference's colour, as EXPLODE gives the block's
|
||||
/// objects.
|
||||
fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, tag: &str, props: &cadcraft_doc::AttribProps) -> Common {
|
||||
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(tag))));
|
||||
// An attribute without a definition follows the reference.
|
||||
let base = def.map(|de| de.common.clone()).unwrap_or_else(|| Common { color: Color::ByBlock, ..Common::default() });
|
||||
let mut c = props.over(&base);
|
||||
if c.layer == "0" {
|
||||
c.layer.clone_from(&e.common.layer);
|
||||
}
|
||||
if c.color == Color::ByBlock {
|
||||
c.color = e.common.color;
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
//! grip begins, with the reference re-evaluated under the cursor as its preview. Typing a number
|
||||
//! at a distance's grip sets the distance.
|
||||
|
||||
use cadcraft_doc::color::Color;
|
||||
use cadcraft_doc::dynblock::{self, GripPart, GripShape};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Lineweight, Transparency};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -50,26 +51,59 @@ fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, and its
|
||||
/// attributes (what a preview draws, in the preview colour: solid fills are left out so they
|
||||
/// don't cover the outline and text).
|
||||
fn drawn(d: &Drawing, h: Handle) -> Vec<EntityKind> {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) else { return Vec::new() };
|
||||
/// The properties an object of a block draws with in reference `r`: on layer 0 it is on the
|
||||
/// reference's layer, and its ByBlock colour, linetype, lineweight and transparency are the
|
||||
/// reference's (as the renderer draws a block's contents).
|
||||
fn under(c: &Common, r: &Common) -> Common {
|
||||
let mut c = c.clone();
|
||||
if c.layer == "0" {
|
||||
c.layer.clone_from(&r.layer);
|
||||
}
|
||||
if c.color == Color::ByBlock {
|
||||
c.color = r.color;
|
||||
}
|
||||
if c.linetype.eq_ignore_ascii_case("ByBlock") {
|
||||
c.linetype.clone_from(&r.linetype);
|
||||
}
|
||||
if c.lineweight == Lineweight::ByBlock {
|
||||
c.lineweight = r.lineweight;
|
||||
}
|
||||
if c.transparency == Transparency::ByBlock {
|
||||
c.transparency = r.transparency;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, in the
|
||||
/// block's order (fills under the outline and text), and its attributes, each with the
|
||||
/// properties it draws with.
|
||||
fn drawn(d: &Drawing, h: Handle) -> Vec<Entity> {
|
||||
let Some(e) = d.entity(h) else { return Vec::new() };
|
||||
let EntityKind::Insert(ins) = &e.kind else { return Vec::new() };
|
||||
let Some(b) = d.block(&ins.block) else { return Vec::new() };
|
||||
let r = &e.common;
|
||||
let m = ins.transform(b.base.xy());
|
||||
let mut out: Vec<EntityKind> = b
|
||||
let mut out: Vec<Entity> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
|
||||
.filter(|e| !matches!(&e.kind, EntityKind::Hatch(h) if h.solid))
|
||||
.take(MAX_PREVIEW)
|
||||
.map(|e| {
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
let mut kind = e.kind.clone();
|
||||
kind.transform(&m);
|
||||
Entity { handle: Handle(0), common: under(&e.common, r), kind }
|
||||
})
|
||||
.collect();
|
||||
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| EntityKind::Text(a.text.clone())));
|
||||
// An attribute draws with its definition's properties, except those set on it.
|
||||
let by_block = Common { layer: "0".into(), color: Color::ByBlock, linetype: "ByBlock".into(), lineweight: Lineweight::ByBlock, ..r.clone() };
|
||||
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| {
|
||||
let def = b.entities.iter().find_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag) => Some(&e.common),
|
||||
_ => None,
|
||||
});
|
||||
let common = under(&a.props.over(def.unwrap_or(&by_block)), r);
|
||||
Entity { handle: Handle(0), common, kind: EntityKind::Text(a.text.clone()) }
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -121,15 +155,20 @@ impl Interactive for DynGripEdit {
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
/// The rubber band from the grip.
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
vec![crate::cmd::helpers::line(self.from, c)]
|
||||
}
|
||||
|
||||
/// The reference re-evaluated at the cursor, drawn as it will look (its own colours, so
|
||||
/// text stays readable on a filled block).
|
||||
fn preview_objects(&self, s: &Session, c: Vec2) -> Vec<Entity> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut d = d.clone();
|
||||
let mut out = match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
|
||||
match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
|
||||
Ok(()) => drawn(&d, self.handle),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
out.push(crate::cmd::helpers::line(self.from, c));
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,8 @@ fn reference_count(d: &Drawing, key: &str) -> usize {
|
||||
fn describe(d: &Drawing, b: &Block, host: Option<&str>) -> Value {
|
||||
let refs = reference_count(d, &b.name);
|
||||
let status = if refs == 0 && b.xref_status == XrefStatus::Loaded { "Unreferenced" } else { b.xref_status.label() };
|
||||
let dir = host.and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
|
||||
let found = b.xref_path.as_deref().and_then(|p| load::find(p, dir.as_deref())).map(|f| f.to_string_lossy().into_owned());
|
||||
let found =
|
||||
b.xref_path.as_deref().and_then(|p| load::find(p, host.map(std::path::Path::new), false).ok()).map(|f| f.to_string_lossy().into_owned());
|
||||
json!({
|
||||
"name": b.name,
|
||||
"path": b.xref_path,
|
||||
@@ -146,7 +146,7 @@ fn attach(s: &mut Session, name: &str, path: &str, overlay: bool) -> Result<Stri
|
||||
b.xref_path = Some(path.to_string());
|
||||
b.xref_overlay = overlay;
|
||||
d.blocks.insert(name.to_string(), Arc::new(b));
|
||||
let out = load::load(&mut d, name, host.as_deref());
|
||||
let out = load::load(&mut d, name, host.as_deref(), true);
|
||||
match out.first() {
|
||||
Some(o) if o.status == XrefStatus::Loaded => {}
|
||||
Some(o) => return Err(bad("xattach", format!("{path} can't be attached: {}", o.detail))),
|
||||
@@ -227,11 +227,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
"reload" | "r" => {
|
||||
let names = named(s, "-xref", p)?;
|
||||
let mut out = Vec::new();
|
||||
for n in &names {
|
||||
let d = s.doc_mut()?;
|
||||
out.extend(load::load(d, n, host.as_deref()));
|
||||
}
|
||||
let out = load::load_each(s.doc_mut()?, &names, host.as_deref(), false);
|
||||
echo_all(s, &out);
|
||||
Ok(
|
||||
json!({ "xrefs": out.iter().filter(|o| !o.name.is_empty()).map(|o| json!({"name": o.name, "status": o.status.label(), "detail": o.detail})).collect::<Vec<_>>() }),
|
||||
@@ -271,14 +267,13 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"path" | "p" => {
|
||||
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
|
||||
let names = named(s, "-xref", p)?;
|
||||
let mut out = Vec::new();
|
||||
let d = s.doc_mut()?;
|
||||
for n in &names {
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(b) = d.blocks.get_mut(n) {
|
||||
Arc::make_mut(b).xref_path = Some(path.clone());
|
||||
}
|
||||
out.extend(load::load(d, n, host.as_deref()));
|
||||
}
|
||||
let out = load::load_each(d, &names, host.as_deref(), true);
|
||||
echo_all(s, &out);
|
||||
Ok(json!({ "xrefs": names, "path": path }))
|
||||
}
|
||||
@@ -367,8 +362,10 @@ fn run_xopen(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = d.block(&name).ok_or_else(|| bad("xopen", format!("no xref \"{name}\"")))?;
|
||||
let path = b.xref_path.clone().ok_or_else(|| bad("xopen", format!("\"{name}\" is not an xref")))?;
|
||||
let host = host(s);
|
||||
let dir = host.as_deref().and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
|
||||
let file = load::find(&path, dir.as_deref()).ok_or_else(|| bad("xopen", format!("{path}: file not found")))?;
|
||||
let file = load::find(&path, host.as_deref().map(std::path::Path::new), false).map_err(|m| match m {
|
||||
load::Missing::NotFound => bad("xopen", format!("{path}: file not found")),
|
||||
load::Missing::Refused(r) => bad("xopen", format!("{path}: not opened, {r}")),
|
||||
})?;
|
||||
s.execute("open", &json!({ "path": file.to_string_lossy() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! `xref|name`.
|
||||
//!
|
||||
//! A path is looked for as given (relative paths against the host drawing's folder), then by its
|
||||
//! file name in the host drawing's folder. A referenced drawing's own attached xrefs load with it
|
||||
//! (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
|
||||
//! file name in the host drawing's folder. A path stored in a drawing is followed only to this
|
||||
//! computer or the host drawing's network share ([`crate::doc_path`]); one that isn't followed and
|
||||
//! has no file of its name beside the host leaves the xref unresolved. A referenced drawing's own
|
||||
//! attached xrefs load with it (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
@@ -13,6 +15,8 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
|
||||
|
||||
use crate::doc_path::{self, Refused};
|
||||
|
||||
/// Most objects the xrefs of one drawing load in one go (nested xrefs included).
|
||||
pub(crate) const MAX_XREF_OBJECTS: usize = 4_000_000;
|
||||
/// Most referenced files read in one go (nested xrefs included).
|
||||
@@ -20,6 +24,9 @@ const MAX_XREF_FILES: usize = 1000;
|
||||
/// Largest referenced file read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_XREF_BYTES: u64 = 512 << 20;
|
||||
/// Most bytes read from referenced files in one go (nested xrefs included).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_XREF_TOTAL_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// The kinds of named symbols an object can refer to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
@@ -119,16 +126,38 @@ struct Ctx {
|
||||
chain: Vec<PathBuf>,
|
||||
objects: usize,
|
||||
files: usize,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
bytes: u64,
|
||||
/// Messages about nested xrefs.
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The folder of the host drawing at `host` (a file path).
|
||||
fn folder(host: Option<&str>) -> Option<PathBuf> {
|
||||
let p = Path::new(host?).parent()?;
|
||||
fn folder(host: Option<&Path>) -> Option<PathBuf> {
|
||||
let p = host?.parent()?;
|
||||
Some(if p.as_os_str().is_empty() { PathBuf::from(".") } else { p.to_path_buf() })
|
||||
}
|
||||
|
||||
/// Why a referenced drawing wasn't found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Missing {
|
||||
NotFound,
|
||||
/// The saved path isn't followed, and there is no file of its name beside the host.
|
||||
Refused(Refused),
|
||||
}
|
||||
|
||||
impl Missing {
|
||||
fn outcome(self, path: &str) -> (XrefStatus, String) {
|
||||
match self {
|
||||
Self::NotFound => {
|
||||
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
|
||||
(XrefStatus::NotFound, format!("{path}: {why}"))
|
||||
}
|
||||
Self::Refused(r) => (XrefStatus::Unresolved, format!("{path}: not opened, {r}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
@@ -138,58 +167,94 @@ fn canonical(p: &Path) -> PathBuf {
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// Where to look for a referenced drawing saved as `path`: the path itself (a relative one against
|
||||
/// the host's folder), then its file name in the host's folder. Both separators work everywhere.
|
||||
fn candidates(path: &str, dir: Option<&Path>) -> Vec<PathBuf> {
|
||||
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
|
||||
let p = PathBuf::from(norm.trim());
|
||||
/// Where to look for a referenced drawing at `path` in the drawing at `host`: the path itself (a
|
||||
/// relative one against the host's folder), then its file name in the host's folder. Both
|
||||
/// separators work everywhere. A path stored in a drawing goes through [`doc_path::resolve`]
|
||||
/// (the refusal is returned); a path the user just `chosen` is used as given.
|
||||
fn candidates(path: &str, host: Option<&Path>, chosen: bool) -> (Vec<PathBuf>, Option<Refused>) {
|
||||
let path = path.trim();
|
||||
let mut out = Vec::new();
|
||||
match dir {
|
||||
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
|
||||
_ => out.push(p.clone()),
|
||||
let mut refused = None;
|
||||
if chosen {
|
||||
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
|
||||
let p = PathBuf::from(norm);
|
||||
match folder(host) {
|
||||
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
|
||||
_ => out.push(p),
|
||||
}
|
||||
} else {
|
||||
match doc_path::resolve(path, host) {
|
||||
Ok(p) => out.push(p),
|
||||
Err(Refused::Invalid) => {}
|
||||
Err(r) => refused = Some(r),
|
||||
}
|
||||
}
|
||||
if let (Some(d), Some(f)) = (dir, p.file_name()) {
|
||||
out.push(d.join(f));
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
if host.is_some()
|
||||
&& !name.is_empty()
|
||||
&& let Ok(p) = doc_path::resolve(name, host)
|
||||
&& !out.contains(&p)
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
(out, refused)
|
||||
}
|
||||
|
||||
/// The file a referenced drawing saved as `path` is loaded from, if there is one.
|
||||
pub(crate) fn find(path: &str, dir: Option<&Path>) -> Option<PathBuf> {
|
||||
/// The file a referenced drawing at `path` (saved in the drawing at `host`, or just `chosen` by
|
||||
/// the user) is loaded from.
|
||||
pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<PathBuf, Missing> {
|
||||
if path.trim().is_empty() {
|
||||
return None;
|
||||
return Err(Missing::NotFound);
|
||||
}
|
||||
let (files, refused) = candidates(path, host, chosen);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
candidates(path, dir).into_iter().find(|p| p.is_file())
|
||||
}
|
||||
let found = files.into_iter().find(|p| p.is_file());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = candidates(path, dir);
|
||||
let found: Option<PathBuf> = {
|
||||
let _ = files;
|
||||
None
|
||||
}
|
||||
};
|
||||
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
|
||||
}
|
||||
|
||||
/// Read a drawing file through the host's io hooks.
|
||||
fn read(file: &Path) -> Result<Drawing, String> {
|
||||
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
|
||||
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
|
||||
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let len = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
|
||||
if len > MAX_XREF_BYTES {
|
||||
return Err(format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20));
|
||||
use std::io::Read;
|
||||
let left = MAX_XREF_TOTAL_BYTES.saturating_sub(cx.bytes);
|
||||
let limit = MAX_XREF_BYTES.min(left);
|
||||
let mut f = std::fs::File::open(file).map_err(|e| e.to_string())?;
|
||||
let len = f.metadata().map_err(|e| e.to_string())?.len();
|
||||
let too_big = || {
|
||||
if limit < MAX_XREF_BYTES {
|
||||
format!("the referenced files are larger than {} MB together", MAX_XREF_TOTAL_BYTES >> 20)
|
||||
} else {
|
||||
format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20)
|
||||
}
|
||||
};
|
||||
if len > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
f.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
let n = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
|
||||
if n > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
cx.bytes = cx.bytes.saturating_add(n);
|
||||
(hooks.read)(&bytes, &file.to_string_lossy())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (hooks, file);
|
||||
let _ = (hooks, file, cx);
|
||||
Err("files are not available on the web".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load every xref of `d` that isn't unloaded; `host` is the drawing's own path.
|
||||
/// Load every xref of `d` that isn't unloaded from the paths saved in it; `host` is the drawing's
|
||||
/// own path.
|
||||
pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
|
||||
let names: Vec<String> = d
|
||||
.blocks
|
||||
@@ -197,28 +262,44 @@ pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
|
||||
.filter(|b| b.xref_path.is_some() && b.xref_status != XrefStatus::Unloaded && !b.name.contains('|'))
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
names.iter().flat_map(|n| load(d, n, host)).collect()
|
||||
load_each(d, &names, host, false)
|
||||
}
|
||||
|
||||
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path. Returns
|
||||
/// the outcome, then notes about its nested xrefs.
|
||||
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>) -> Vec<Outcome> {
|
||||
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
|
||||
let mut cx = Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, notes: Vec::new() };
|
||||
let dir = folder(host);
|
||||
let (status, detail) = load_in(d, name, dir.as_deref(), &mut cx, 0);
|
||||
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path, and
|
||||
/// `chosen` says the xref's path was just given by the user (not read from a drawing). Returns the
|
||||
/// outcome, then notes about its nested xrefs.
|
||||
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool) -> Vec<Outcome> {
|
||||
load_each(d, std::slice::from_ref(&name.to_string()), host, chosen)
|
||||
}
|
||||
|
||||
/// [`load`] the xrefs `names` within one set of limits.
|
||||
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
|
||||
let mut cx = Ctx::new(host);
|
||||
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn new(host: Option<&str>) -> Self {
|
||||
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
|
||||
Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, bytes: 0, notes: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
fn load_with(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool, cx: &mut Ctx) -> Vec<Outcome> {
|
||||
let host = host.map(Path::new).filter(|p| p.file_name().is_some());
|
||||
let (status, detail) = load_in(d, name, host, chosen, cx, 0);
|
||||
let key = d.block(name).map(|b| b.name.clone()).unwrap_or_else(|| name.to_string());
|
||||
let mut out = vec![Outcome { name: key, status, detail }];
|
||||
out.extend(cx.notes.into_iter().map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
|
||||
out.extend(cx.notes.drain(..).map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
|
||||
out
|
||||
}
|
||||
|
||||
fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
|
||||
fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
|
||||
let Some(b) = d.block(name) else { return (XrefStatus::NotFound, "no such xref".into()) };
|
||||
let key = b.name.clone();
|
||||
let Some(path) = b.xref_path.clone() else { return (XrefStatus::Unresolved, "not an xref".into()) };
|
||||
clear(d, &key);
|
||||
let (status, detail) = match fetch(&path, dir, cx, depth) {
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
@@ -234,15 +315,12 @@ fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth:
|
||||
}
|
||||
|
||||
/// Read the drawing referenced as `path` and load its own attached xrefs into it.
|
||||
fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
|
||||
fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
|
||||
let unresolved = |m: &str| (XrefStatus::Unresolved, m.to_string());
|
||||
if depth >= MAX_BLOCK_DEPTH {
|
||||
return Err(unresolved("xrefs are nested too deeply"));
|
||||
}
|
||||
let Some(file) = find(path, dir) else {
|
||||
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
|
||||
return Err((XrefStatus::NotFound, format!("{path}: {why}")));
|
||||
};
|
||||
let file = find(path, host, chosen).map_err(|m| m.outcome(path))?;
|
||||
let canon = canonical(&file);
|
||||
if cx.chain.contains(&canon) {
|
||||
return Err(unresolved("circular reference"));
|
||||
@@ -251,13 +329,12 @@ fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(
|
||||
return Err(unresolved("too many referenced files"));
|
||||
}
|
||||
cx.files += 1;
|
||||
let mut x = read(&file).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
|
||||
let mut x = read(&file, cx).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
|
||||
cx.chain.push(canon);
|
||||
let nested: Vec<String> =
|
||||
x.blocks.values().filter(|b| b.xref_path.is_some() && !b.xref_overlay && !b.name.contains('|')).map(|b| b.name.clone()).collect();
|
||||
let xdir = file.parent().map(Path::to_path_buf);
|
||||
for n in nested {
|
||||
let (status, detail) = load_in(&mut x, &n, xdir.as_deref(), cx, depth + 1);
|
||||
let (status, detail) = load_in(&mut x, &n, Some(&file), false, cx, depth + 1);
|
||||
if status != XrefStatus::Loaded {
|
||||
cx.notes.push(format!("nested xref \"{n}\" not loaded: {detail}"));
|
||||
}
|
||||
@@ -430,18 +507,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_the_host_folder_then_by_name() {
|
||||
let dir = Path::new("/proj/plans");
|
||||
let c = candidates("parts/a.dxf", Some(dir));
|
||||
assert_eq!(c, vec![dir.join("parts/a.dxf"), dir.join("a.dxf")]);
|
||||
let c = candidates("/abs/b.dwg", Some(dir));
|
||||
assert_eq!(c, vec![PathBuf::from("/abs/b.dwg"), dir.join("b.dwg")]);
|
||||
let dir = std::env::temp_dir().join("proj").join("plans");
|
||||
let host = dir.join("host.dxf");
|
||||
let c = |path: &str, chosen| candidates(path, Some(&host), chosen);
|
||||
assert_eq!(c("parts/a.dxf", false), (vec![dir.join("parts").join("a.dxf"), dir.join("a.dxf")], None));
|
||||
let abs = std::env::temp_dir().join("abs").join("b.dwg");
|
||||
assert_eq!(c(&abs.to_string_lossy(), false), (vec![abs.clone(), dir.join("b.dwg")], None));
|
||||
if !cfg!(windows) {
|
||||
// A Windows path saved elsewhere still finds the file next to the host.
|
||||
let c = candidates("C:\\jobs\\site.dwg", Some(dir));
|
||||
assert_eq!(c.last(), Some(&dir.join("site.dwg")));
|
||||
assert_eq!(c("C:\\jobs\\site.dwg", false), (vec![dir.join("site.dwg")], None));
|
||||
}
|
||||
assert_eq!(candidates("a.dxf", None), vec![PathBuf::from("a.dxf")]);
|
||||
assert!(find("", Some(dir)).is_none());
|
||||
// Paths to other computers are only looked for beside the host.
|
||||
assert_eq!(c(r"\\192.0.2.1\share\x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
|
||||
assert_eq!(c("file://192.0.2.1/s/x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
|
||||
// Without a host drawing a relative path names no file.
|
||||
assert_eq!(candidates("a.dxf", None, false), (vec![], None));
|
||||
assert_eq!(candidates("a.dxf", None, true), (vec![PathBuf::from("a.dxf")], None));
|
||||
assert_eq!(find("", Some(&host), false), Err(Missing::NotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("parameters", "Parameters Manager", run_parameters)
|
||||
.menu(&["Tools", "Parametric", "Parameters Manager"])
|
||||
.alias(&["par", "parametersclose"])
|
||||
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}"),
|
||||
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}; {name, rename: newName} renames it in every expression"),
|
||||
CommandSpec::new("constraintsettings", "Constraint Settings", run_constraintsettings)
|
||||
.menu(&["Tools", "Parametric", "Constraint Settings"])
|
||||
.alias(&["csettings"])
|
||||
@@ -873,6 +873,10 @@ fn run_parameters(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other(format!("`{name}` is used by `{}`", row.name)));
|
||||
}
|
||||
*s.doc_mut()? = d;
|
||||
} else if let (Some(old), Some(to)) = (str_param(p, "name"), str_param(p, "rename")) {
|
||||
let mut d = s.doc()?.clone();
|
||||
cs::rename_parameter(&mut d, old.trim(), to.trim()).map_err(err)?;
|
||||
*s.doc_mut()? = d;
|
||||
} else if let Some(name) = str_param(p, "name") {
|
||||
let expr = match (str_param(p, "expr"), p.get("value").and_then(Value::as_f64)) {
|
||||
(Some(e), _) => e.to_string(),
|
||||
@@ -952,6 +956,13 @@ fn run_constraintsettings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set.angle_tolerance = f64_or(p, "angleTolerance", set.angle_tolerance).clamp(0.0, 45.0);
|
||||
set.bars_visible = bool_or(p, "barsVisible", set.bars_visible);
|
||||
set.dims_visible = bool_or(p, "dimsVisible", set.dims_visible);
|
||||
// The checkboxes act like Show All / Hide All: exceptions are relative to the old value and would flip.
|
||||
if set.bars_visible != cur.bars_visible {
|
||||
set.bar_exceptions.clear();
|
||||
}
|
||||
if set.dims_visible != cur.dims_visible {
|
||||
set.dim_exceptions.clear();
|
||||
}
|
||||
if let Some(t) = p.get("barTransparency").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||||
set.bar_transparency = t.clamp(0.0, 90.0) as u8;
|
||||
}
|
||||
@@ -1136,8 +1147,9 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
let purged = cs::purge_invalid(&mut d);
|
||||
let restored = cs::restore_fixed(&mut d, before);
|
||||
let satisfied = cs::all_satisfied(&d);
|
||||
if purged.is_empty() && satisfied {
|
||||
if purged.is_empty() && !restored && satisfied {
|
||||
return;
|
||||
}
|
||||
let mut msg = Vec::new();
|
||||
|
||||
@@ -56,6 +56,42 @@ fn json_geometric_and_dimensional_constraints() {
|
||||
assert_eq!(inspect["glyphs"][0]["kind"], "Horizontal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_parameter_rewrites_the_expressions_that_use_it() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("parameters", &json!({ "name": "user1", "expr": "4" })).unwrap();
|
||||
s.execute("dcaligned", &json!({ "h1": a, "expr": "user1*2" })).unwrap();
|
||||
s.execute("dcaligned", &json!({ "h1": b, "expr": "d1+1" })).unwrap();
|
||||
let names = |s: &Session| -> Vec<(String, String)> {
|
||||
cadcraft_constraints::parameter_table(s.doc().unwrap()).into_iter().map(|r| (r.name, r.expr)).collect()
|
||||
};
|
||||
|
||||
s.execute("parameters", &json!({ "name": "user1", "rename": "width" })).unwrap();
|
||||
assert_eq!(names(&s), vec![("d1".into(), "width*2".into()), ("d2".into(), "d1+1".into()), ("width".into(), "4".into())]);
|
||||
assert!((ends(&s, &a).0.dist(ends(&s, &a).1) - 8.0).abs() < 1e-6);
|
||||
|
||||
// A dimensional constraint's name too, and only the whole name: d1 is not part of d10.
|
||||
s.execute("parameters", &json!({ "name": "d10", "expr": "3" })).unwrap();
|
||||
s.execute("parameters", &json!({ "name": "d2", "expr": "d1+d10" })).unwrap();
|
||||
s.execute("parameters", &json!({ "name": "d1", "rename": "base" })).unwrap();
|
||||
assert_eq!(
|
||||
names(&s),
|
||||
vec![("base".into(), "width*2".into()), ("d2".into(), "base+d10".into()), ("width".into(), "4".into()), ("d10".into(), "3".into())]
|
||||
);
|
||||
|
||||
// Taken and invalid names are refused and change nothing; undo takes a rename back.
|
||||
let before = s.doc().unwrap().clone();
|
||||
for to in ["width", "d10", "1x", "pi", ""] {
|
||||
assert!(s.execute("parameters", &json!({ "name": "base", "rename": to })).is_err(), "{to}");
|
||||
}
|
||||
assert!(s.execute("parameters", &json!({ "name": "nothing", "rename": "x" })).is_err());
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(names(&s)[0], ("d1".into(), "width*2".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_are_resolved_and_erase_purges() {
|
||||
let mut s = Session::new();
|
||||
@@ -231,3 +267,83 @@ fn delconstraint_rejects_referenced_dimension() {
|
||||
s.execute("delconstraint", &json!({ "ids": ids })).unwrap();
|
||||
assert!(s.doc().unwrap().constraints.is_empty());
|
||||
}
|
||||
|
||||
/// The angle between the two rays from the vertex to the far end of each line: the angle
|
||||
/// DCANGULAR draws.
|
||||
fn drawn_angle(s: &Session, a: &str, b: &str) -> f64 {
|
||||
let ((a0, a1), (b0, b1)) = (ends(s, a), ends(s, b));
|
||||
let (o, _, _) = cadcraft_geom::line_line_infinite(a0, a1, b0, b1).unwrap();
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(o) > q.dist(o) { p } else { q };
|
||||
let (u, v) = (far(a0, a1) - o, far(b0, b1) - o);
|
||||
u.cross(v).atan2(u.dot(v)).abs().to_degrees()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcangular_constrains_the_drawn_angle_whichever_way_the_lines_run() {
|
||||
// #1134: the second line drawn towards the shared vertex got the supplement of the value.
|
||||
for b_pts in [[[2.0, 10.0], [0.0, 0.0]], [[0.0, 0.0], [2.0, 10.0]]] {
|
||||
let mut s = Session::new();
|
||||
s.viewport_px = (1000.0, 1000.0);
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, b_pts[0], b_pts[1]);
|
||||
s.cmdline("dcangular").unwrap();
|
||||
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.6, 3.0))).unwrap();
|
||||
assert!(s.prompt_text().contains("78.69"), "{b_pts:?}: {}", s.prompt_text());
|
||||
s.cmdline("100").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let got = drawn_angle(&s, &a, &b);
|
||||
assert!((got - 100.0).abs() < 1e-6, "{b_pts:?}: drawn angle {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Same rule as the overlay in `ui-egui/src/parametric.rs` that draws dynamic constraints.
|
||||
fn dim_shown(s: &Session, h: &str) -> bool {
|
||||
let d = s.doc().unwrap();
|
||||
let set = &d.parametric.settings;
|
||||
let c = d.constraints.iter().find(|c| c.refs.iter().any(|r| r.handle.hex() == h)).unwrap();
|
||||
set.dims_visible != c.refs.iter().any(|r| set.dim_exceptions.contains(&r.handle))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_dims_checkbox_clears_exceptions() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("dclinear", &json!({ "h1": a, "expr": "10" })).unwrap();
|
||||
s.execute("dcdisplay.hideall", &json!({})).unwrap();
|
||||
let b = line(&mut s, [20.0, 0.0], [25.0, 0.0]);
|
||||
s.execute("dclinear", &json!({ "h1": b, "expr": "5" })).unwrap();
|
||||
let r = s.execute("dcdisplay", &json!({ "mode": "show", "handles": [b] })).unwrap();
|
||||
assert_eq!(r["exceptions"], json!([b]));
|
||||
assert!(!dim_shown(&s, &a) && dim_shown(&s, &b));
|
||||
// Re-stating the current value keeps the exceptions.
|
||||
s.execute("constraintsettings", &json!({ "dimsVisible": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().parametric.settings.dim_exceptions.len(), 1);
|
||||
// Ticking "Show dynamic constraints" shows all of them, like Show All.
|
||||
s.execute("constraintsettings", &json!({ "dimsVisible": true })).unwrap();
|
||||
assert!(s.doc().unwrap().parametric.settings.dim_exceptions.is_empty());
|
||||
assert!(dim_shown(&s, &a) && dim_shown(&s, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_bars_checkbox_clears_exceptions() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("gchorizontal", &json!({ "h1": a })).unwrap();
|
||||
s.execute("constraintbar.hideall", &json!({})).unwrap();
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("gchorizontal", &json!({ "h1": b })).unwrap();
|
||||
let r = s.execute("constraintbar", &json!({ "mode": "show", "handles": [b] })).unwrap();
|
||||
assert_eq!(r["exceptions"], json!([b]));
|
||||
// Same rule as the bar overlay in `ui-egui/src/parametric.rs`.
|
||||
let bar = |s: &Session, h: &str| {
|
||||
let set = &s.doc().unwrap().parametric.settings;
|
||||
set.bars_visible != set.bar_exceptions.contains(&Handle::parse_hex(h).unwrap())
|
||||
};
|
||||
assert!(!bar(&s, &a) && bar(&s, &b));
|
||||
s.execute("constraintsettings", &json!({ "barsVisible": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().parametric.settings.bar_exceptions.len(), 1);
|
||||
s.execute("constraintsettings", &json!({ "barsVisible": true })).unwrap();
|
||||
assert!(s.doc().unwrap().parametric.settings.bar_exceptions.is_empty());
|
||||
assert!(bar(&s, &a) && bar(&s, &b));
|
||||
}
|
||||
@@ -129,6 +129,12 @@ pub fn last_curve_end(s: &Session) -> Option<(Vec2, Vec2)> {
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
// Arcs are stored CCW: one drawn clockwise ended on its start point, so continue
|
||||
// from the last picked point (LASTPOINT), leaving it in the drawing direction.
|
||||
let tol = 1e-9 * g.radius.max(1.0);
|
||||
if g.start_point().near(s.last_point, tol) && !g.end_point().near(s.last_point, tol) {
|
||||
return Some((g.start_point(), -Vec2::from_angle(g.start).perp()));
|
||||
}
|
||||
return Some((g.end_point(), Vec2::from_angle(g.end).perp()));
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed => {
|
||||
|
||||
@@ -755,9 +755,12 @@ impl Interactive for CircleM {
|
||||
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
match (self.mode, n) {
|
||||
(0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
|
||||
(0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()),
|
||||
(0, _) if self.diameter => {
|
||||
Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()
|
||||
}
|
||||
(0, _) => {
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied());
|
||||
let p =
|
||||
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
|
||||
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
|
||||
}
|
||||
(1, k) => Prompt::new(
|
||||
@@ -890,7 +893,7 @@ impl Interactive for PolygonM {
|
||||
(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),
|
||||
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::machines::number;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(crate) mod breakline;
|
||||
pub(crate) mod mline;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -133,6 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))),
|
||||
];
|
||||
v.extend(mline::specs());
|
||||
v.extend(breakline::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -217,7 +219,7 @@ impl ArcV {
|
||||
ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))],
|
||||
ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))],
|
||||
ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))],
|
||||
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), (E, Ask::Point)],
|
||||
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
|
||||
ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))],
|
||||
ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))],
|
||||
ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))],
|
||||
@@ -231,7 +233,7 @@ impl ArcV {
|
||||
],
|
||||
}
|
||||
}
|
||||
fn build(self, v: &[Val]) -> Option<EntityKind> {
|
||||
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
|
||||
let p = |i: usize| v.get(i).and_then(|x| x.p());
|
||||
let n = |i: usize| v.get(i).and_then(|x| x.n());
|
||||
let a = match self {
|
||||
@@ -243,7 +245,14 @@ impl ArcV {
|
||||
ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)),
|
||||
ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Cse => Some(Arc::from_start_center_end(p(1)?, p(0)?, p(2)?)),
|
||||
// Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the
|
||||
// other way around: swapping the start/end angles is the mirror arc through the
|
||||
// same two endpoints. (Continue below flips differently, by negating the start
|
||||
// tangent; both are "reverse", but only here is the chord fixed by the picks.)
|
||||
ArcV::Cse => {
|
||||
let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?);
|
||||
Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a })
|
||||
}
|
||||
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
|
||||
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
|
||||
ArcV::CircleCd => {
|
||||
@@ -338,6 +347,11 @@ impl Interactive for SeqM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some((st, dir)) = self.cont {
|
||||
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
|
||||
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
|
||||
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
|
||||
// modifiers so it remains UI-agnostic.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
return match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
@@ -380,7 +394,7 @@ impl Interactive for SeqM {
|
||||
if self.vals.len() < self.v.steps().len() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match self.v.build(&self.vals) {
|
||||
match self.v.build(&self.vals, s.ctrl_down) {
|
||||
Some(k) => {
|
||||
if let EntityKind::Circle(c) = &k {
|
||||
let r = c.radius;
|
||||
@@ -397,8 +411,9 @@ impl Interactive for SeqM {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some((st, d)) = self.cont {
|
||||
let d = if s.ctrl_down { -d } else { d };
|
||||
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
|
||||
}
|
||||
let Some((_, ask)) = self.ask() else { return Vec::new() };
|
||||
@@ -407,7 +422,7 @@ impl Interactive for SeqM {
|
||||
vals.push(v);
|
||||
let mut out: Vec<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
|
||||
if vals.len() == self.v.steps().len() {
|
||||
out.extend(self.v.build(&vals));
|
||||
out.extend(self.v.build(&vals, s.ctrl_down));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -460,7 +475,10 @@ fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?,
|
||||
};
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?;
|
||||
added(s.add_entity(arc(&a))?)
|
||||
let h = s.add_entity(arc(&a))?;
|
||||
// The next continuation starts where this arc was drawn to, not at its stored CCW end.
|
||||
s.last_point = e;
|
||||
added(h)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -485,6 +503,8 @@ impl Interactive for ArcContinueM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
|
||||
// Ctrl flips the direction, exactly as in `SeqM::input`.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
@@ -495,8 +515,14 @@ impl Interactive for ArcContinueM {
|
||||
_ => Err(other("Point required.")),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.from.and_then(|(st, d)| curves::arc_sed(st, c, d)).map(|a| vec![arc(&a)]).unwrap_or_default()
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.from
|
||||
.and_then(|(st, dir)| {
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
curves::arc_sed(st, c, dir)
|
||||
})
|
||||
.map(|a| vec![arc(&a)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +601,7 @@ impl Interactive for TanCircleM {
|
||||
1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT),
|
||||
_ => {
|
||||
let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1));
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance();
|
||||
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
|
||||
}
|
||||
}
|
||||
@@ -1692,7 +1718,7 @@ impl Interactive for RectM2 {
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! BREAKLINE (Express Tools): one polyline from the first point to the second with a break
|
||||
//! symbol at a location along it, the line optionally extended past both points.
|
||||
//!
|
||||
//! The symbol is a run of vertices between two attachment points. The built-in one is our own
|
||||
//! zigzag; the Block option takes a block of the drawing that marks its attachment points with
|
||||
//! two POINT objects on the Defpoints layer and draws the symbol with lines and polylines.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::added;
|
||||
use super::curves::MAX_GEN;
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::cmd::modify2::as_lwpoly;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
|
||||
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
|
||||
.params(
|
||||
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
|
||||
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
|
||||
)
|
||||
.interactive(|s| Ok(Box::new(BreaklineM::new(s)))),
|
||||
]
|
||||
}
|
||||
|
||||
/// The values BREAKLINE offers as defaults: the symbol block (empty = built-in), its size and
|
||||
/// how far the line runs past the two points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BreaklineOptions {
|
||||
pub block: String,
|
||||
pub size: f64,
|
||||
pub extension: f64,
|
||||
}
|
||||
|
||||
impl Default for BreaklineOptions {
|
||||
fn default() -> Self {
|
||||
BreaklineOptions { block: String::new(), size: 1.0, extension: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// A break symbol in its own coordinates: the vertices from one attachment point to the other.
|
||||
struct Symbol {
|
||||
path: Vec<PolyVertex>,
|
||||
}
|
||||
|
||||
impl Symbol {
|
||||
/// Our own zigzag: up and across to the other side of the line, one unit between the
|
||||
/// attachment points and one unit tall.
|
||||
fn built_in() -> Self {
|
||||
let pts = [(-0.5, 0.0), (-0.25, 0.5), (0.25, -0.5), (0.5, 0.0)];
|
||||
Symbol { path: pts.into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() }
|
||||
}
|
||||
|
||||
fn of_block(d: &Drawing, name: &str) -> Result<Self> {
|
||||
let b = d.block(name).ok_or_else(|| other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut anchors = Vec::new();
|
||||
let mut pieces: Vec<Vec<PolyVertex>> = Vec::new();
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Point(p) if e.common.layer.eq_ignore_ascii_case("Defpoints") => anchors.push(p.p.xy()),
|
||||
k => {
|
||||
if let Some(pl) = as_lwpoly(k).filter(|pl| !pl.closed && pl.vertices.len() >= 2)
|
||||
&& pieces.len() < MAX_GEN
|
||||
{
|
||||
pieces.push(pl.vertices);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, z] = anchors[..] else {
|
||||
return Err(other(format!("Block \"{name}\" needs exactly two points on the Defpoints layer.")));
|
||||
};
|
||||
let tol = a.dist(z) * 1e-6;
|
||||
if !(tol > 0.0 && tol.is_finite()) {
|
||||
return Err(other(format!("The two Defpoints points of block \"{name}\" coincide.")));
|
||||
}
|
||||
// Chain the pieces end to end from one attachment point until the other is reached.
|
||||
let mut path = vec![PolyVertex::new(a)];
|
||||
let mut at = a;
|
||||
while !at.near(z, tol) {
|
||||
let next = pieces.iter().position(|p| p.first().is_some_and(|v| v.p.near(at, tol)) || p.last().is_some_and(|v| v.p.near(at, tol)));
|
||||
let Some(i) = next else {
|
||||
return Err(other(format!("The lines of block \"{name}\" must run from one Defpoints point to the other.")));
|
||||
};
|
||||
let mut piece = pieces.swap_remove(i);
|
||||
if !piece.first().is_some_and(|v| v.p.near(at, tol)) {
|
||||
piece = reversed(&piece);
|
||||
}
|
||||
if let Some(last) = path.last_mut() {
|
||||
last.bulge = piece.first().map_or(0.0, |v| v.bulge);
|
||||
}
|
||||
path.extend(piece.iter().skip(1).copied());
|
||||
at = path.last().map_or(z, |v| v.p);
|
||||
}
|
||||
Ok(Symbol { path })
|
||||
}
|
||||
|
||||
fn span(&self) -> f64 {
|
||||
match (self.path.first(), self.path.last()) {
|
||||
(Some(a), Some(z)) => a.p.dist(z.p),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol scaled by `k`, its attachment points on the line through `from` along `u`.
|
||||
fn placed(&self, from: Vec2, u: Vec2, k: f64) -> Vec<PolyVertex> {
|
||||
let (Some(a), Some(z)) = (self.path.first(), self.path.last()) else { return Vec::new() };
|
||||
let ax = (z.p - a.p).normalized();
|
||||
self.path
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let q = v.p - a.p;
|
||||
let (x, y) = (q.dot(ax) * k, q.dot(ax.perp()) * k);
|
||||
PolyVertex::with_bulge(from + u * x + u.perp() * y, v.bulge)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The same vertices run backwards, each arc keeping its side.
|
||||
fn reversed(vs: &[PolyVertex]) -> Vec<PolyVertex> {
|
||||
let n = vs.len();
|
||||
(0..n)
|
||||
.rev()
|
||||
.filter_map(|i| {
|
||||
let v = vs.get(i)?;
|
||||
let bulge = if i == 0 { 0.0 } else { -vs.get(i - 1).map_or(0.0, |p| p.bulge) };
|
||||
Some(PolyVertex::with_bulge(v.p, bulge))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dimscale(s: &Session) -> f64 {
|
||||
s.doc().ok().map(|d| d.header.f64("DIMSCALE", 1.0)).filter(|k| k.is_finite() && *k > 0.0).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn symbol(s: &Session, block: &str) -> Result<Symbol> {
|
||||
if block.is_empty() { Ok(Symbol::built_in()) } else { Symbol::of_block(s.doc()?, block) }
|
||||
}
|
||||
|
||||
/// The breakline's vertices: the extension, the line up to the symbol, the symbol and the rest of
|
||||
/// the line. The symbol is centred on `at` projected onto the line (the midpoint without it) and
|
||||
/// kept between the two points.
|
||||
fn breakline_vertices(sym: &Symbol, p1: Vec2, p2: Vec2, at: Option<Vec2>, k: f64, ext: f64) -> Result<Vec<PolyVertex>> {
|
||||
let len = p1.dist(p2);
|
||||
let u = (p2 - p1).normalized();
|
||||
if !(len.is_finite() && len > 0.0) || u == Vec2::ZERO {
|
||||
return Err(other("The two points of the breakline must differ."));
|
||||
}
|
||||
let span = sym.span() * k;
|
||||
if !span.is_finite() || span <= 0.0 {
|
||||
return Err(other("The break symbol has no size."));
|
||||
}
|
||||
if span > len {
|
||||
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
|
||||
}
|
||||
let t = at.map_or(len / 2.0, |c| (c - p1).dot(u)).clamp(span / 2.0, len - span / 2.0);
|
||||
let mut pts = vec![PolyVertex::new(p1 - u * ext)];
|
||||
pts.extend(sym.placed(p1 + u * (t - span / 2.0), u, k));
|
||||
pts.push(PolyVertex::new(p2 + u * ext));
|
||||
let mut out: Vec<PolyVertex> = Vec::with_capacity(pts.len());
|
||||
for v in pts {
|
||||
if !v.p.is_finite() {
|
||||
return Err(other("The breakline is too large."));
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.p.near(v.p, len * 1e-12) => l.bulge = v.bulge,
|
||||
_ => out.push(v),
|
||||
}
|
||||
}
|
||||
if let Some(l) = out.last_mut() {
|
||||
l.bulge = 0.0;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn run_breakline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p1 = point_req("breakline", p, "p1")?;
|
||||
let p2 = point_req("breakline", p, "p2")?;
|
||||
let size = size_param("breakline", p, "size", false)?.unwrap_or(1.0);
|
||||
let ext = size_param("breakline", p, "extension", true)?.unwrap_or(0.0);
|
||||
let sym = symbol(s, str_param(p, "block").unwrap_or(""))?;
|
||||
let vs = breakline_vertices(&sym, p1, p2, point_param(p, "at"), size * dimscale(s), ext)?;
|
||||
added(s.add_entity(lwpoly(vs, false))?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Ask {
|
||||
Points,
|
||||
Block,
|
||||
Size,
|
||||
Extension,
|
||||
}
|
||||
|
||||
struct BreaklineM {
|
||||
opts: BreaklineOptions,
|
||||
ask: Ask,
|
||||
p1: Option<Vec2>,
|
||||
p2: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl BreaklineM {
|
||||
fn new(s: &Session) -> Self {
|
||||
BreaklineM { opts: s.last_used.breakline.clone(), ask: Ask::Points, p1: None, p2: None }
|
||||
}
|
||||
fn block_label(&self) -> String {
|
||||
if self.opts.block.is_empty() { "(built-in)".into() } else { self.opts.block.clone() }
|
||||
}
|
||||
fn finish(&self, s: &mut Session, at: Option<Vec2>) -> Result<Step> {
|
||||
let (Some(p1), Some(p2)) = (self.p1, self.p2) else { return Ok(Step::Cancel) };
|
||||
let vs = breakline_vertices(&symbol(s, &self.opts.block)?, p1, p2, at, self.opts.size * dimscale(s), self.opts.extension)?;
|
||||
s.add_entity(lwpoly(vs, false))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BreaklineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BREAKLINE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo(format!("Block= {}, Size= {:.4}, Extension= {:.4}", self.block_label(), self.opts.size, self.opts.extension));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.ask, self.p1, self.p2) {
|
||||
(Ask::Block, ..) => {
|
||||
Prompt::new("Enter name of breakline symbol block (. for the built-in symbol)", Accept::TEXT).default(self.block_label())
|
||||
}
|
||||
(Ask::Size, ..) => Prompt::new("Breakline symbol size", Accept::NUMBER).default(format!("{:.4}", self.opts.size)),
|
||||
(Ask::Extension, ..) => Prompt::new("Breakline extension distance", Accept::NUMBER).default(format!("{:.4}", self.opts.extension)),
|
||||
(Ask::Points, None, _) => Prompt::new("Specify first point for breakline", Accept::POINT).kw(&["Block", "Size", "Extension"]),
|
||||
(Ask::Points, Some(a), None) => Prompt::new("Specify second point for breakline", Accept::POINT).base(a),
|
||||
(Ask::Points, Some(_), Some(_)) => Prompt::new("Specify location for break symbol", Accept::POINT).default("Midpoint"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.ask, i) {
|
||||
(Ask::Block, Input::Text(t) | Input::Keyword(t)) => {
|
||||
let t = t.trim();
|
||||
let name = if t == "." { String::new() } else { t.to_string() };
|
||||
symbol(s, &name)?;
|
||||
self.opts.block = name;
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
(Ask::Size, Input::Text(t)) => {
|
||||
self.opts.size = number(&t).filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| other("Requires a positive value."))?;
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
(Ask::Extension, Input::Text(t)) => {
|
||||
self.opts.extension = number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires zero or a positive value."))?;
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
(Ask::Block | Ask::Size | Ask::Extension, Input::Enter) => self.ask = Ask::Points,
|
||||
(Ask::Points, Input::Keyword(k)) if self.p1.is_none() => {
|
||||
self.ask = match k.as_str() {
|
||||
"Block" => Ask::Block,
|
||||
"Size" => Ask::Size,
|
||||
"Extension" => Ask::Extension,
|
||||
_ => Ask::Points,
|
||||
};
|
||||
}
|
||||
(Ask::Points, Input::Point(p)) => match (self.p1, self.p2) {
|
||||
(None, _) => self.p1 = Some(p),
|
||||
(Some(a), None) => {
|
||||
let span = symbol(s, &self.opts.block)?.span() * self.opts.size * dimscale(s);
|
||||
let len = a.dist(p);
|
||||
if len.is_nan() || len < span {
|
||||
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
|
||||
}
|
||||
self.p2 = Some(p);
|
||||
}
|
||||
(Some(_), Some(_)) => return self.finish(s, Some(p)),
|
||||
},
|
||||
(Ask::Points, Input::Enter) if self.p2.is_some() => return self.finish(s, None),
|
||||
(Ask::Points, Input::Enter) => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
s.last_used.breakline = self.opts.clone();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.ask, self.p1, self.p2) {
|
||||
(Ask::Points, Some(a), None) => vec![line(a, c)],
|
||||
(Ask::Points, Some(a), Some(b)) => symbol(s, &self.opts.block)
|
||||
.and_then(|sym| breakline_vertices(&sym, a, b, Some(c), self.opts.size * dimscale(s), self.opts.extension))
|
||||
.map(|vs| vec![lwpoly(vs, false)])
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,8 +576,7 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
|
||||
let aa = a.clone();
|
||||
cut_across(&mut b, u, &outer_of(&bo), &aa, k);
|
||||
}
|
||||
Tool::MergedTee => return Err(other("Merged tee is not available yet.")),
|
||||
Tool::ClosedTee | Tool::OpenTee => {
|
||||
Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => {
|
||||
let path = path_seg(&a, k).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let bm = b.miters();
|
||||
let (top, bottom) = outer(&bo).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
@@ -593,16 +592,36 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
|
||||
_ => (sb, bottom),
|
||||
};
|
||||
let near = if near_e == top { lt } else { lb };
|
||||
a = trim_at(&a, k, at, keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
|
||||
if tool == Tool::OpenTee {
|
||||
// Merged tee: the first one's inner elements run on to the second one's nearest inner element.
|
||||
let inner = if tool == Tool::MergedTee && ao.len() > 2 {
|
||||
(0..bo.len())
|
||||
.filter(|e| *e != top && *e != bottom)
|
||||
.filter_map(|e| meet(&path, &line(e)?))
|
||||
.min_by(|x, y| (x - at).abs().total_cmp(&(y - at).abs()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
a = trim_at(&a, k, inner.unwrap_or(at), keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
|
||||
if tool != Tool::ClosedTee {
|
||||
// Open the near outer element of the second multiline where the first meets it.
|
||||
let ak = if keep_before { a.vertices.len().saturating_sub(2) } else { 0 };
|
||||
let (am, aoff) = (a.miters(), a.offsets());
|
||||
if let Some((t2, b2)) = outer(&aoff) {
|
||||
let al = |e: usize| aoff.get(e).and_then(|d| element_line(&a, &am, ak, *d));
|
||||
if let (Some(x), Some(y)) = (al(t2).and_then(|l| meet(&near, &l)), al(b2).and_then(|l| meet(&near, &l))) {
|
||||
let (lt2, lb2) = (al(t2), al(b2));
|
||||
if let (Some(x), Some(y)) = (lt2.and_then(|l| meet(&near, &l)), lb2.and_then(|l| meet(&near, &l))) {
|
||||
add_cut(&mut b, u, near_e, x, y);
|
||||
}
|
||||
// ...and the first one's outer elements stop at it.
|
||||
if inner.is_some() {
|
||||
for (e, l) in [(t2, lt2), (b2, lb2)] {
|
||||
if let Some(l) = l
|
||||
&& let Some(x) = meet(&l, &near)
|
||||
{
|
||||
add_cut(&mut a, ak, e, x, if keep_before { l.len() } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,28 @@ fn arc_continue_from_last_line() {
|
||||
assert!(near(as_arc(&last(&s)).center, 0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_continue_from_last_picked_end_of_three_point_arc() {
|
||||
// (picked points, end of the continuation, its center): both arcs leave their last
|
||||
// picked point heading -Y, so the continuation is a quarter turn on that side.
|
||||
let cases = [
|
||||
// Clockwise: stored CCW, the last picked point (2,0) is the arc's start.
|
||||
("arc 0,0 1,1 2,0", "3,-1", (3.0, 0.0)),
|
||||
// Counter-clockwise: the last picked point (0,0) is the arc's end.
|
||||
("arc 2,0 1,1 0,0", "-1,-1", (-1.0, 0.0)),
|
||||
];
|
||||
for (draw, end, center) in cases {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(draw).unwrap();
|
||||
s.cmdline("arc").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline(end).unwrap();
|
||||
let a = as_arc(&last(&s));
|
||||
assert!(near(a.center, center.0, center.1), "{draw}: {a:?}");
|
||||
assert!((a.sweep() - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{draw}: reversed tangent {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- CIRCLE ----------------
|
||||
|
||||
#[test]
|
||||
@@ -350,6 +372,118 @@ fn revcloud_command_line() {
|
||||
assert_eq!(kinds(&s).len(), 4);
|
||||
}
|
||||
|
||||
// ---------------- BREAKLINE ----------------
|
||||
|
||||
fn breakline_points(s: &Session) -> Vec<(f64, f64)> {
|
||||
match last(s) {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
assert!(!p.closed);
|
||||
p.vertices.iter().map(|v| ((v.p.x * 1e9).round() / 1e9, (v.p.y * 1e9).round() / 1e9)).collect()
|
||||
}
|
||||
k => panic!("not a polyline: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakline_json_symbol_size_extension_and_location() {
|
||||
let mut s = Session::new();
|
||||
// One polyline: the line to the symbol, the zigzag centred on the midpoint, the rest of the line.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
|
||||
// Size scales the symbol, Extension runs the line past both points, `at` is projected onto the line.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "extension": 1, "at": [2, 3]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
|
||||
// A location past the end keeps the symbol on the line; its end then is the line's end.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "at": [100, 0]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (8.0, 0.0), (8.5, 1.0), (9.5, -1.0), (10.0, 0.0)]);
|
||||
// The symbol turns with the line.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [0, 10]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (0.0, 4.5), (-0.5, 4.75), (0.5, 5.25), (0.0, 5.5), (0.0, 10.0)]);
|
||||
// DIMSCALE scales the size.
|
||||
s.doc_mut().unwrap().header.set_f64("DIMSCALE", 4.0);
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (3.0, 0.0), (4.0, 2.0), (6.0, -2.0), (7.0, 0.0), (10.0, 0.0)]);
|
||||
|
||||
let n = s.doc().unwrap().model.len();
|
||||
for p in [
|
||||
json!({"p1": [0, 0], "p2": [3, 0]}),
|
||||
json!({"p1": [0, 0], "p2": [0, 0]}),
|
||||
json!({"p1": [0, 0], "p2": [10, 0], "size": 0}),
|
||||
json!({"p1": [0, 0], "p2": [10, 0], "extension": -1}),
|
||||
json!({"p1": [0, 0], "p2": [10, 0], "block": "NOPE"}),
|
||||
json!({"p1": [0, 0], "p2": [1e308, 0], "extension": 1e308}),
|
||||
] {
|
||||
assert!(s.execute("breakline", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakline_block_symbol_runs_between_its_defpoints() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.current", &json!({"name": "Defpoints"})).unwrap();
|
||||
s.execute("point", &json!({"at": [0, 0]})).unwrap();
|
||||
s.execute("point", &json!({"at": [2, 0]})).unwrap();
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
// Drawn in pieces, one of them backwards: they are chained from one point to the other.
|
||||
s.execute("line", &json!({"points": [[2, 0], [1, 1]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [1, 1]]})).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": "PEAK", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "PEAK"})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.0, 0.0), (5.0, 1.0), (6.0, 0.0), (10.0, 0.0)]);
|
||||
// Without two Defpoints points the block is refused.
|
||||
s.execute("line", &json!({"points": [[0, 5], [1, 5]]})).unwrap();
|
||||
let l = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("block", &json!({"name": "BARE", "base": [0, 0], "handles": [l], "keep": "delete"})).unwrap();
|
||||
assert!(s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "BARE"})).is_err());
|
||||
|
||||
s.cmdline("breakline b PEAK 0,0 10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(breakline_points(&s).len(), 5);
|
||||
s.cmdline("breakline b nope").unwrap();
|
||||
assert!(s.prompt_text().contains("block"), "an unknown block asks again: {}", s.prompt_text());
|
||||
s.cmdline(".").unwrap();
|
||||
s.cmdline("0,0 10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(breakline_points(&s).len(), 6, "`.` goes back to the built-in symbol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakline_command_line_prompts_options_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.start("breakline").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify first point for breakline"), "{}", s.prompt_text());
|
||||
assert!(s.prompt_text().contains("Block") && s.prompt_text().contains("Size") && s.prompt_text().contains("Extension"), "{}", s.prompt_text());
|
||||
s.cmdline("0,0").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify second point for breakline"), "{}", s.prompt_text());
|
||||
// Too short for the symbol: asked again.
|
||||
s.cmdline("0.5,0").unwrap();
|
||||
assert!(s.prompt_text().contains("second point"), "{}", s.prompt_text());
|
||||
s.cmdline("10,0").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify location for break symbol") && s.prompt_text().contains("Midpoint"), "{}", s.prompt_text());
|
||||
assert!(matches!(s.preview(Vec2::new(3.0, 1.0)).first().map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.vertices.len() == 6));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
|
||||
assert_eq!(s.state().unwrap().undo.len(), 1, "one undo step");
|
||||
|
||||
// Size and Extension at the first prompt, a picked location; remembered for the next time.
|
||||
s.cmdline("breakline s 2 e 1 0,0 10,0 2,3").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
|
||||
assert_eq!(s.state().unwrap().undo.len(), 2);
|
||||
s.cmdline("breakline s").unwrap();
|
||||
assert!(s.prompt_text().contains("2.0000"), "{}", s.prompt_text());
|
||||
s.cmdline("0").unwrap();
|
||||
assert!(s.prompt_text().contains("size"), "zero is refused: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "undo removes the whole breakline");
|
||||
}
|
||||
|
||||
// ---------------- WIPEOUT ----------------
|
||||
|
||||
#[test]
|
||||
@@ -622,6 +756,7 @@ fn new_draw_commands_survive_garbage_input() {
|
||||
"revcloud.polygonal",
|
||||
"revcloud.freehand",
|
||||
"wipeout",
|
||||
"breakline",
|
||||
"3dpoly",
|
||||
"mline",
|
||||
"helix",
|
||||
|
||||
@@ -316,7 +316,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
|
||||
page.landscape = l;
|
||||
}
|
||||
let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
|
||||
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
|
||||
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
|
||||
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
|
||||
|
||||
@@ -14,6 +14,8 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
|
||||
@@ -43,13 +43,13 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
|
||||
fn command_id(target: &str) -> Option<String> {
|
||||
pub fn command_id(target: &str) -> Option<String> {
|
||||
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
|
||||
find_command(&t).map(|c| c.id.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` can be typed as an alias: one word of printable characters, not a command name.
|
||||
fn alias_name(a: &str) -> std::result::Result<String, String> {
|
||||
pub fn alias_name(a: &str) -> std::result::Result<String, String> {
|
||||
let a = a.trim();
|
||||
if a.is_empty() || a.chars().count() > MAX_ALIAS_CHARS || a.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '{') {
|
||||
return Err(format!("`{a}` can't be an alias"));
|
||||
@@ -193,6 +193,7 @@ fn run_reinit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("aliasedit", "Command Aliases", run_aliasedit)
|
||||
.menu(&["Tools", "Customize", "Edit Command Aliases (PGP)..."])
|
||||
.params("{file?: path to a .pgp alias file | text?: its contents, set?: {alias: command}, remove?: [alias], clear?: bool} → {aliases, file, loaded, warnings}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
|
||||
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], addPoints?: [[x,y]], addBoundary?: [hex], removeLoops?: [zero-based index] (geometry edits require one hatch), style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
CommandSpec::new("hatchpick", "Hatch Pick", pick::run_hatchpick)
|
||||
.params("{points?: [[x,y]] (internal points), handles?: [hex] (closed objects)} (typed: Points, Objects, oRigin or Hatch, then the picks): the Hatch and Gradient dialog's picks, kept in the session; changes nothing")
|
||||
.enabled(has_doc)
|
||||
|
||||
@@ -137,6 +137,18 @@ fn reshaped(prev: &Hatch, h: &Hatch) -> bool {
|
||||
h.loops.iter().any(|l| !prev.loops.iter().any(|q| q.vertices == l.vertices))
|
||||
}
|
||||
|
||||
/// HATCHEDIT may add or remove whole loops without moving the ones it kept. New associative
|
||||
/// loops must still match their source objects; a hatch moved on its own must not keep its link.
|
||||
fn valid_loop_edit(prev: &Hatch, h: &Hatch, after: &Drawing) -> bool {
|
||||
if prev.loops.len().saturating_mul(h.loops.len()) > 4_000_000 {
|
||||
return false;
|
||||
}
|
||||
h.loops.iter().all(|l| {
|
||||
prev.loops.iter().any(|old| old.vertices == l.vertices)
|
||||
|| (!l.sources.is_empty() && matches!(refill(after, after, l), Refill::Loop(vs) if same_shape(&vs, &l.vertices)))
|
||||
})
|
||||
}
|
||||
|
||||
/// What happens to one associative hatch after a command; `None` when nothing changes.
|
||||
fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked: &mut usize) -> Option<(Hatch, Option<String>)> {
|
||||
let prev = match before.entity(handle).map(|e| &e.kind) {
|
||||
@@ -153,7 +165,7 @@ fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked:
|
||||
}
|
||||
match prev {
|
||||
// Moved or reshaped on its own: no longer tied to the boundary.
|
||||
Some(p) if reshaped(p, h) => {
|
||||
Some(p) if reshaped(p, h) && !valid_loop_edit(p, h, after) => {
|
||||
detach(&mut nh);
|
||||
return Some((nh, None));
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::assoc::{detach, same_shape};
|
||||
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style};
|
||||
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, dedup_loops, gradient_name, island_style, loops_at, loops_from_handles};
|
||||
use crate::cmd::helpers::lwpoly;
|
||||
use crate::cmd::{bad, bool_or, point_param, size_param, str_param, targets};
|
||||
use crate::cmd::{bad, bool_or, point_param, points_param, size_param, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on hatches edited by one call.
|
||||
@@ -259,9 +259,111 @@ fn check_numbers(p: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary changes are prepared before any hatch is edited, so a bad pick cannot leave a
|
||||
/// partially changed drawing. Loop numbers refer to the hatch before this request.
|
||||
fn boundary_changes(s: &Session, p: &Value, hs: &[Handle]) -> Result<(Vec<HatchLoop>, Vec<usize>)> {
|
||||
let given = |key: &str| p.get(key).is_some_and(|v| !v.is_null());
|
||||
if !["addPoints", "addBoundary", "removeLoops"].into_iter().any(given) {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
if hs.len() != 1 {
|
||||
return Err(bad("hatchedit", "edit the boundaries of one hatch at a time"));
|
||||
}
|
||||
let mut added = Vec::new();
|
||||
if given("addPoints") {
|
||||
if s.doc()?.space_of(hs[0]) != Some(s.space()) {
|
||||
return Err(bad("hatchedit", "open the hatch's space before adding points"));
|
||||
}
|
||||
let points = points_param(p, "addPoints").ok_or_else(|| bad("hatchedit", "`addPoints` is an array of finite [x, y] points"))?;
|
||||
if points.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} points")));
|
||||
}
|
||||
for q in points {
|
||||
if !q.is_finite() {
|
||||
return Err(bad("hatchedit", "`addPoints` must contain finite points"));
|
||||
}
|
||||
added.extend(loops_at(s, q)?);
|
||||
if added.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} added loops")));
|
||||
}
|
||||
}
|
||||
}
|
||||
if given("addBoundary") {
|
||||
let raw =
|
||||
p.get("addBoundary").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`addBoundary` is an array of closed object handles"))?;
|
||||
if raw.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} boundary objects")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let space = d.space_of(hs[0]);
|
||||
let mut handles = Vec::with_capacity(raw.len());
|
||||
for value in raw {
|
||||
let h = value.as_str().and_then(Handle::parse_hex).ok_or_else(|| bad("hatchedit", "invalid `addBoundary` handle"))?;
|
||||
let e = d.entity(h).ok_or_else(|| bad("hatchedit", format!("boundary object {} not found", h.hex())))?;
|
||||
if d.space_of(h) != space || closed_loop(d, e).is_none() {
|
||||
return Err(bad("hatchedit", format!("boundary object {} must be closed and in the hatch's space", h.hex())));
|
||||
}
|
||||
handles.push(h);
|
||||
}
|
||||
if !handles.is_empty() {
|
||||
added.extend(loops_from_handles(s, &handles)?);
|
||||
}
|
||||
}
|
||||
let mut removed = Vec::new();
|
||||
if given("removeLoops") {
|
||||
let raw =
|
||||
p.get("removeLoops").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`removeLoops` is an array of zero-based loop numbers"))?;
|
||||
if raw.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} loop numbers")));
|
||||
}
|
||||
let count = hs
|
||||
.first()
|
||||
.and_then(|h| s.doc().ok()?.entity(*h))
|
||||
.and_then(|e| match &e.kind {
|
||||
EntityKind::Hatch(h) => Some(h.loops.len()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0);
|
||||
for value in raw {
|
||||
let index = value
|
||||
.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|n| *n < count)
|
||||
.ok_or_else(|| bad("hatchedit", "`removeLoops` contains an invalid loop number"))?;
|
||||
if !removed.contains(&index) {
|
||||
removed.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((added, removed))
|
||||
}
|
||||
|
||||
fn edit_loops(h: &mut Hatch, added: &[HatchLoop], removed: &[usize]) -> Result<()> {
|
||||
if added.is_empty() && removed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
h.loops = h.loops.drain(..).enumerate().filter_map(|(i, l)| (!removed.contains(&i)).then_some(l)).collect();
|
||||
h.loops.extend_from_slice(added);
|
||||
dedup_loops(&mut h.loops);
|
||||
if h.loops.is_empty() {
|
||||
return Err(bad("hatchedit", "a hatch needs at least one boundary loop"));
|
||||
}
|
||||
if h.loops.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("a hatch may have at most {MAX_LOOPS} loops")));
|
||||
}
|
||||
let outlines: Vec<Vec<Vec2>> = h.loops.iter().map(outline).collect();
|
||||
for (i, l) in h.loops.iter_mut().enumerate() {
|
||||
let p = outlines.get(i).and_then(|v| v.first()).copied();
|
||||
let depth = p.map_or(0, |q| outlines.iter().enumerate().filter(|(j, other)| *j != i && point_in_polygon(other, q)).count());
|
||||
l.outer = depth % 2 == 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p)?;
|
||||
let hs = hatches(s, p)?;
|
||||
let (added, removed) = boundary_changes(s, p, &hs)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
|
||||
None => None,
|
||||
@@ -308,6 +410,14 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut budget = MAX_LOOPS;
|
||||
for handle in &hs {
|
||||
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
|
||||
edit_loops(&mut h, &added, &removed)?;
|
||||
if h.associative && h.loops.iter().any(|l| l.sources.is_empty()) && p.get("associative").and_then(Value::as_bool) != Some(false) {
|
||||
let missing = link(s.doc()?, *handle, &mut h, None, &mut budget);
|
||||
if missing != 0 {
|
||||
detach(&mut h);
|
||||
notes.push(format!("Hatch {}: {missing} new boundary loop(s) could not be linked; it is now non-associative.", handle.hex()));
|
||||
}
|
||||
}
|
||||
properties(&mut h, p);
|
||||
if let Some(g) = &grad {
|
||||
g.apply(&mut h);
|
||||
@@ -360,6 +470,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Ask {
|
||||
Option,
|
||||
AddPoint,
|
||||
AddObject,
|
||||
Remove,
|
||||
Style,
|
||||
Pattern,
|
||||
Scale,
|
||||
@@ -370,7 +483,18 @@ enum Ask {
|
||||
Link,
|
||||
}
|
||||
|
||||
const OPTIONS: [&str; 7] = ["Disassociate", "Style", "Properties", "recreate Boundary", "ASsociate", "separate Hatches", "Origin"];
|
||||
const OPTIONS: [&str; 10] = [
|
||||
"Disassociate",
|
||||
"Style",
|
||||
"Properties",
|
||||
"recreate Boundary",
|
||||
"ASsociate",
|
||||
"separate Hatches",
|
||||
"Origin",
|
||||
"add Point",
|
||||
"add Object",
|
||||
"remove Boundary",
|
||||
];
|
||||
|
||||
/// -HATCHEDIT: select hatches, then one option.
|
||||
pub(crate) struct HatchEditM {
|
||||
@@ -422,6 +546,9 @@ impl Interactive for HatchEditM {
|
||||
let h = self.first(s);
|
||||
match self.ask {
|
||||
Ask::Option => Prompt::new("Enter a hatch option", Accept::TEXT).kw(&OPTIONS),
|
||||
Ask::AddPoint => Prompt::new("Pick an internal point for the new boundary", Accept::POINT),
|
||||
Ask::AddObject => Prompt::new("Select closed boundary objects to add", Accept::SELECT),
|
||||
Ask::Remove => Prompt::new("Enter a boundary loop number to remove (starting at 1)", Accept::TEXT),
|
||||
Ask::Style => {
|
||||
let cur = match h.map_or(0, |h| h.style) {
|
||||
1 => "Outer",
|
||||
@@ -475,10 +602,24 @@ impl Interactive for HatchEditM {
|
||||
Some("recreate Boundary") => self.ask = Ask::Recreate,
|
||||
Some("ASsociate") => self.ask = Ask::Link,
|
||||
Some("Origin") => self.ask = Ask::Origin,
|
||||
Some("add Point") => self.ask = Ask::AddPoint,
|
||||
Some("add Object") => self.ask = Ask::AddObject,
|
||||
Some("remove Boundary") => self.ask = Ask::Remove,
|
||||
_ => s.echo("Choose one of the options."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Ask::AddPoint, Input::Point(p)) => self.apply(s, json!({ "addPoints": [[p.x, p.y]] })),
|
||||
(Ask::AddObject, Input::Pick(hs)) => self.apply(s, json!({ "addBoundary": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })),
|
||||
(Ask::AddPoint | Ask::AddObject, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::AddPoint | Ask::AddObject, _) => Ok(Step::Continue),
|
||||
(Ask::Remove, _) => {
|
||||
let Some(index) = text.as_deref().and_then(|t| t.parse::<usize>().ok()).and_then(|n| n.checked_sub(1)) else {
|
||||
s.echo("Enter a positive boundary loop number.");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
self.apply(s, json!({ "removeLoops": [index] }))
|
||||
}
|
||||
(Ask::Style, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::Style, _) => match text.as_deref().and_then(island_style) {
|
||||
Some(st) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2};
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -9,6 +9,7 @@ use crate::units::{format_angle, format_distance};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod regions;
|
||||
mod shapes;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
@@ -41,8 +42,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.noundo(),
|
||||
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
|
||||
CommandSpec::new("texts", "Query Texts", run_texts)
|
||||
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
|
||||
.noundo(),
|
||||
@@ -71,28 +73,119 @@ fn fmt_a(s: &Session, v: f64) -> String {
|
||||
format_angle(v, h.0, h.1)
|
||||
}
|
||||
|
||||
fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
|
||||
/// The angle of `d` up from the XY plane.
|
||||
fn angle_from_xy(d: Vec3) -> f64 {
|
||||
d.z.atan2(d.xy().len())
|
||||
}
|
||||
|
||||
fn dist_text(s: &Session, a: Vec3, b: Vec3) -> String {
|
||||
let d = b - a;
|
||||
format!(
|
||||
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = 0\nDelta X = {}, Delta Y = {}, Delta Z = {}",
|
||||
fmt_d(s, a.dist(b)),
|
||||
fmt_a(s, d.angle()),
|
||||
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = {}\nDelta X = {}, Delta Y = {}, Delta Z = {}",
|
||||
fmt_d(s, (b - a).len()),
|
||||
fmt_a(s, d.xy().angle()),
|
||||
fmt_a(s, angle_from_xy(d)),
|
||||
fmt_d(s, d.x),
|
||||
fmt_d(s, d.y),
|
||||
fmt_d(s, 0.0)
|
||||
fmt_d(s, d.z)
|
||||
)
|
||||
}
|
||||
|
||||
/// A point parameter with its Z: the third number when given, else the Z of what is drawn
|
||||
/// there ([`z_at`]).
|
||||
fn point3_req(s: &Session, cmd: &str, p: &Value, key: &str) -> Result<Vec3> {
|
||||
let q = point_req(cmd, p, key)?;
|
||||
let z = p.get(key).and_then(|v| v.get(2)).and_then(Value::as_f64).filter(|z| z.is_finite());
|
||||
Ok(Vec3::new(q.x, q.y, z.unwrap_or_else(|| z_at(s, q))))
|
||||
}
|
||||
|
||||
fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("dist", p, "p1")?;
|
||||
let b = point_req("dist", p, "p2")?;
|
||||
Ok(json!({ "distance": a.dist(b), "angle": (b - a).angle().to_degrees(), "dx": b.x - a.x, "dy": b.y - a.y, "message": dist_text(s, a, b) }))
|
||||
let a = point3_req(s, "dist", p, "p1")?;
|
||||
let b = point3_req(s, "dist", p, "p2")?;
|
||||
let d = b - a;
|
||||
Ok(json!({
|
||||
"distance": d.len(),
|
||||
"angle": d.xy().angle().to_degrees(),
|
||||
"angleFromXY": angle_from_xy(d).to_degrees(),
|
||||
"dx": d.x,
|
||||
"dy": d.y,
|
||||
"dz": d.z,
|
||||
"message": dist_text(s, a, b),
|
||||
}))
|
||||
}
|
||||
|
||||
fn id_text(s: &Session, a: Vec3) -> String {
|
||||
format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, a.z))
|
||||
}
|
||||
|
||||
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("id", p, "at")?;
|
||||
s.last_point = a;
|
||||
Ok(json!({ "x": a.x, "y": a.y, "message": format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, 0.0)) }))
|
||||
let a = point3_req(s, "id", p, "at")?;
|
||||
s.last_point = a.xy();
|
||||
Ok(json!({ "x": a.x, "y": a.y, "z": a.z, "message": id_text(s, a) }))
|
||||
}
|
||||
|
||||
/// The Z of what is drawn at `p`: the object a pick finds there, at that point, or the current
|
||||
/// elevation (ELEVATION) when there is none. Points come from the canvas in 2D; the object
|
||||
/// under a snapped point gives its Z back, as AutoCAD's ID and DIST report a snapped point's.
|
||||
pub(crate) fn z_at(s: &Session, p: Vec2) -> f64 {
|
||||
let Ok(d) = s.doc() else { return 0.0 };
|
||||
let elevation = d.header.f64("ELEVATION", 0.0);
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
crate::select::pick(d, &space, p, ap)
|
||||
.and_then(|h| d.entity(h))
|
||||
.and_then(|e| entity_z(e, p))
|
||||
.filter(|z| z.is_finite())
|
||||
.unwrap_or(if elevation.is_finite() { elevation } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The Z of `e` at the point of it nearest `p` (seen from above), when it has one there.
|
||||
fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
|
||||
let on_segment = |a: Vec3, b: Vec3| {
|
||||
let d = b.xy() - a.xy();
|
||||
let l2 = d.dot(d);
|
||||
let t = if l2 > 0.0 { ((p - a.xy()).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a.z + (b.z - a.z) * t, (a.xy() + d * t).dist(p))
|
||||
};
|
||||
let along = |pts: &[Vec3], closed: bool| {
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n.saturating_sub(1) };
|
||||
(0..segs)
|
||||
.filter_map(|i| Some(on_segment(*pts.get(i)?, *pts.get((i + 1) % n)?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(z, _)| z)
|
||||
.or_else(|| pts.first().map(|q| q.z))
|
||||
};
|
||||
let n = e.common.extrusion;
|
||||
let plan = n.x.abs() < 1e-9 && n.y.abs() < 1e-9 && n.z != 0.0;
|
||||
match &e.kind {
|
||||
EntityKind::Point(q) => Some(q.p.z),
|
||||
EntityKind::Line(l) => Some(on_segment(l.a, l.b).0),
|
||||
EntityKind::Polyline3d(pl) => along(&pl.points, pl.closed),
|
||||
// Elevation is in the polyline's OCS: down Z for a mirrored one.
|
||||
EntityKind::LwPolyline(pl) if plan => Some(if n.z < 0.0 { -pl.elevation } else { pl.elevation }),
|
||||
// Read in world coordinates already.
|
||||
EntityKind::Circle(c) => Some(c.center.z),
|
||||
EntityKind::Arc(a) => Some(a.center.z),
|
||||
EntityKind::Text(t) | EntityKind::AttDef(cadcraft_doc::Attrib { text: t, .. }) => Some(t.insert.z),
|
||||
EntityKind::MText(t) => Some(t.insert.z),
|
||||
EntityKind::Insert(i) => Some(i.insert.z),
|
||||
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Z at `p` on a 3D face: on the plane of the triangle of its corners that holds `p`.
|
||||
fn face_z(c: &[Vec3; 4], p: Vec2) -> Option<f64> {
|
||||
let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option<f64> {
|
||||
let n = (b - a).cross(d - a);
|
||||
if n.z.abs() < 1e-12 * (1.0 + n.len()) {
|
||||
return None;
|
||||
}
|
||||
let inside = cadcraft_geom::point_in_polygon(&[a.xy(), b.xy(), d.xy()], p);
|
||||
inside.then(|| a.z - (n.x * (p.x - a.x) + n.y * (p.y - a.y)) / n.z)
|
||||
};
|
||||
tri(c[0], c[1], c[2]).or_else(|| if c[3] == c[2] { None } else { tri(c[0], c[2], c[3]) })
|
||||
}
|
||||
|
||||
fn list_text(s: &Session, h: Handle) -> String {
|
||||
@@ -112,23 +205,25 @@ fn list_text(s: &Session, h: Handle) -> String {
|
||||
"\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
|
||||
fmt_d(s, l.a.x),
|
||||
fmt_d(s, l.a.y),
|
||||
fmt_d(s, 0.0),
|
||||
fmt_d(s, l.a.z),
|
||||
fmt_d(s, l.b.x),
|
||||
fmt_d(s, l.b.y),
|
||||
fmt_d(s, 0.0)
|
||||
fmt_d(s, l.b.z)
|
||||
));
|
||||
t.push_str(&format!(
|
||||
"\n Length = {}, Angle in XY Plane = {}",
|
||||
fmt_d(s, l.a.xy().dist(l.b.xy())),
|
||||
fmt_d(s, (l.b - l.a).len()),
|
||||
fmt_a(s, l.a.xy().angle_to(l.b.xy()))
|
||||
));
|
||||
let d = l.b - l.a;
|
||||
t.push_str(&format!("\n Delta X = {}, Delta Y = {}, Delta Z = {}", fmt_d(s, d.x), fmt_d(s, d.y), fmt_d(s, d.z)));
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
t.push_str(&format!(
|
||||
"\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
|
||||
fmt_d(s, c.center.x),
|
||||
fmt_d(s, c.center.y),
|
||||
fmt_d(s, 0.0),
|
||||
fmt_d(s, c.center.z),
|
||||
fmt_d(s, c.radius),
|
||||
fmt_d(s, c.radius * cadcraft_geom::TAU),
|
||||
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
|
||||
@@ -139,7 +234,7 @@ fn list_text(s: &Session, h: Handle) -> String {
|
||||
"\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
|
||||
fmt_d(s, a.center.x),
|
||||
fmt_d(s, a.center.y),
|
||||
fmt_d(s, 0.0),
|
||||
fmt_d(s, a.center.z),
|
||||
fmt_d(s, a.radius),
|
||||
fmt_a(s, a.start),
|
||||
fmt_a(s, a.end)
|
||||
@@ -153,16 +248,20 @@ fn list_text(s: &Session, h: Handle) -> String {
|
||||
} else {
|
||||
t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
|
||||
}
|
||||
// The elevation, in world Z (down Z for a mirrored polyline).
|
||||
let z = entity_z(e, Vec2::ZERO).unwrap_or(p.elevation);
|
||||
t.push_str(&format!("\n elevation {}", fmt_d(s, z)));
|
||||
for v in &p.vertices {
|
||||
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, 0.0)));
|
||||
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, z)));
|
||||
}
|
||||
}
|
||||
EntityKind::Text(tx) => {
|
||||
t.push_str(&format!(
|
||||
"\n Style = \"{}\"\n start point, X={} Y={}\n height {}\n text {}\n rotation angle {}",
|
||||
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
|
||||
tx.style,
|
||||
fmt_d(s, tx.insert.x),
|
||||
fmt_d(s, tx.insert.y),
|
||||
fmt_d(s, tx.insert.z),
|
||||
fmt_d(s, tx.height),
|
||||
tx.value,
|
||||
fmt_a(s, tx.rotation)
|
||||
@@ -183,7 +282,7 @@ fn run_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": texts.join("\n\n") }))
|
||||
}
|
||||
|
||||
fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
let a = cadcraft_geom::shoelace(pts).abs();
|
||||
let n = pts.len();
|
||||
let per: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
|
||||
@@ -205,14 +304,16 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
pub(super) 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) => {
|
||||
// An open polyline is measured as if closed for the area, but its perimeter is its own length (AutoCAD's AREA help).
|
||||
let g = Polyline { vertices: pl.vertices.clone(), closed: true };
|
||||
(g.area().abs(), g.len())
|
||||
let drawn = Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
|
||||
(g.area().abs(), drawn.len())
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let arc = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
@@ -221,7 +322,9 @@ fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
}
|
||||
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()))
|
||||
// The perimeter integrated around the ellipse, not a closed-form estimate (those
|
||||
// fall short as the ellipse flattens: 0.3% on a 100:1 one).
|
||||
(cadcraft_geom::PI * a * b, arc.arc_length_from(0.0, cadcraft_geom::TAU))
|
||||
}
|
||||
EntityKind::Hatch(hh) => {
|
||||
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
|
||||
@@ -335,14 +438,17 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.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 selected = given("selected").map_or(Ok(false), |v| v.as_bool().ok_or_else(|| bad(CMD, "`selected` must be true or false")))?;
|
||||
let limit = count("limit", 500)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
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 selection: std::collections::HashSet<Handle> = if selected { st.selection.iter().copied().collect() } else { Default::default() };
|
||||
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = store
|
||||
.iter()
|
||||
.filter(|e| !selected || selection.contains(&e.handle))
|
||||
.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.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol)))
|
||||
@@ -427,7 +533,7 @@ fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod count;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DistM {
|
||||
pub(super) struct DistM {
|
||||
first: Option<Vec2>,
|
||||
first_source: Option<crate::snap::ViewportPoint>,
|
||||
/// Multiple points: the points so far, the first included.
|
||||
@@ -553,7 +659,8 @@ impl Interactive for DistM {
|
||||
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
|
||||
_ => (a, b),
|
||||
};
|
||||
let t = dist_text(s, a, b);
|
||||
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
|
||||
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
|
||||
for l in t.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
@@ -600,7 +707,7 @@ impl Interactive for IdM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Input::Point(p) = i {
|
||||
let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0));
|
||||
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
|
||||
s.echo(m);
|
||||
}
|
||||
Ok(Step::Done)
|
||||
@@ -662,7 +769,7 @@ enum AreaMode {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AreaM {
|
||||
pub(super) struct AreaM {
|
||||
pts: Vec<Vec2>,
|
||||
mode: AreaMode,
|
||||
total: f64,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! SHAPES: geometry facts for every closed shape in the current space — perimeter, area, centroid,
|
||||
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
|
||||
//! Read-only.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::{area_of, object_area};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "shapes";
|
||||
|
||||
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
|
||||
|
||||
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
|
||||
enum Facts {
|
||||
Shape { perimeter: f64, area: f64, signed: f64, centroid: Option<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
|
||||
Skip(&'static str),
|
||||
}
|
||||
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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(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", 200)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let tolerance = given("tolerance")
|
||||
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
|
||||
.transpose()?;
|
||||
let s: &Session = s;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} });
|
||||
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
|
||||
let tol = tolerance.unwrap_or_else(|| {
|
||||
let ext = d.extents(&st.space);
|
||||
let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) };
|
||||
(diag * 1e-9).max(1e-12)
|
||||
});
|
||||
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
|
||||
let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new();
|
||||
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
|
||||
for e in 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.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
|
||||
{
|
||||
match facts(s, e, tol)? {
|
||||
Facts::Skip(why) => {
|
||||
let entry = skipped.entry(e.kind.type_name()).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1.insert(why);
|
||||
}
|
||||
f => shapes.push((e, f)),
|
||||
}
|
||||
}
|
||||
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
|
||||
let skipped: BTreeMap<&str, Value> =
|
||||
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
|
||||
Ok(json!({
|
||||
"schema": 1,
|
||||
"count": shapes.len(),
|
||||
"returned": page.len(),
|
||||
"offset": offset,
|
||||
"truncated": page.len() < shapes.len().saturating_sub(offset),
|
||||
"shapes": page,
|
||||
"skipped": skipped,
|
||||
}))
|
||||
}
|
||||
|
||||
fn shape_json(e: &Entity, f: &Facts) -> Value {
|
||||
let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null };
|
||||
json!({
|
||||
"handle": e.handle.hex(),
|
||||
"type": e.kind.type_name(),
|
||||
"layer": e.common.layer,
|
||||
"perimeter": perimeter,
|
||||
"area": area,
|
||||
"winding": if *signed < 0.0 { "cw" } else { "ccw" },
|
||||
"centroid": centroid.map(|c| [c.x, c.y]),
|
||||
"selfIntersection": cross.map(|c| [c.x, c.y]),
|
||||
"duplicateVertices": dups.iter().map(|(i, dist)| json!({ "index": i, "distance": dist })).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
Ok(match &e.kind {
|
||||
EntityKind::Circle(c) => {
|
||||
let (area, perimeter) = object_area(s, e.handle)?;
|
||||
Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() }
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
if !geom.is_full() {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
let (area, perimeter) = object_area(s, e.handle)?;
|
||||
Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() }
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
let mut vertices = pl.vertices.clone();
|
||||
let ends_meet = match (vertices.first(), vertices.last()) {
|
||||
(Some(a), Some(b)) => vertices.len() > 1 && a.p.dist(b.p) <= tol,
|
||||
_ => false,
|
||||
};
|
||||
if !pl.closed {
|
||||
if !ends_meet {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
// The last vertex repeats the first: the shape closes there.
|
||||
vertices.pop();
|
||||
}
|
||||
if vertices.len() < 2 {
|
||||
return Ok(Facts::Skip("fewer than two vertices"));
|
||||
}
|
||||
let g = Polyline { vertices, closed: true };
|
||||
let signed = g.area();
|
||||
let n = g.vertices.len();
|
||||
let dups = (0..n)
|
||||
.filter_map(|i| {
|
||||
let dist = g.vertices.get(i)?.p.dist(g.vertices.get((i + 1) % n)?.p);
|
||||
(dist <= tol).then_some((i, dist))
|
||||
})
|
||||
.collect();
|
||||
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let mut pts = sp.tessellate(1e-3);
|
||||
let ends_meet = match (pts.first(), pts.last()) {
|
||||
(Some(a), Some(b)) => pts.len() > 2 && a.dist(*b) <= tol.max(1e-9),
|
||||
_ => false,
|
||||
};
|
||||
if !sp.closed && !ends_meet {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
if ends_meet {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 3 {
|
||||
return Ok(Facts::Skip("degenerate"));
|
||||
}
|
||||
let (area, perimeter) = area_of(&pts);
|
||||
let g = Polyline::from_points(&pts, true);
|
||||
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() }
|
||||
}
|
||||
EntityKind::Arc(_) => Facts::Skip("open"),
|
||||
_ => Facts::Skip("not a closed shape"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shapes with more edges than this report no selfIntersection.
|
||||
const MAX_CROSSING_SEGMENTS: usize = 4_000;
|
||||
|
||||
/// The first crossing between two non-adjacent edges, in edge order.
|
||||
fn first_crossing(g: &Polyline) -> Option<Vec2> {
|
||||
let segs = g.segments();
|
||||
let n = segs.len();
|
||||
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
|
||||
if n > MAX_CROSSING_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
for (i, a) in segs.iter().enumerate() {
|
||||
for (j, b) in segs.iter().enumerate().skip(i + 2) {
|
||||
if i == 0 && j + 1 == n {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = intersect_segments(a, b).first() {
|
||||
return Some(*x);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -40,7 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("layoff", "Layer Off", |s, p| set_obj_layers(s, p, |l| l.on = false))
|
||||
.menu(&["Format", "Layer Tools", "Layer Off"])
|
||||
.params("{handles?}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::single("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
|
||||
// Layer Off stays active until Enter or Esc, as in AutoCAD (#1418): one object per
|
||||
// pick and the prompt comes straight back.
|
||||
.interactive(|_| Ok(Box::new(SelectRun::repeat("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
|
||||
CommandSpec::new("laylck", "Lock Layer", |s, p| set_obj_layers(s, p, |l| l.locked = true))
|
||||
.menu(&["Format", "Layer Tools", "Lock Layer"])
|
||||
.params("{handles?}")
|
||||
|
||||
@@ -121,31 +121,50 @@ impl SelectPhase {
|
||||
}
|
||||
|
||||
/// "Select objects:", then run a JSON command on them as `{handles}`. With a pickfirst selection it
|
||||
/// runs at once, exactly like the JSON form does on the current selection.
|
||||
/// runs at once, exactly like the JSON form does on the current selection. A repeating run acts on
|
||||
/// every pick and asks for the next object, the way AutoCAD's LAYOFF does.
|
||||
pub struct SelectRun {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
/// Single-object prompt (e.g. LAYFRZ's "Select an object on the layer to be frozen").
|
||||
single: Option<&'static str>,
|
||||
/// Stay active after each pick, until Enter or Esc (LAYOFF).
|
||||
repeat: bool,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl SelectRun {
|
||||
pub fn new(id: &'static str, name: &'static str) -> Self {
|
||||
SelectRun { id, name, single: None, sel: SelectPhase::default() }
|
||||
SelectRun { id, name, single: None, repeat: false, sel: SelectPhase::default() }
|
||||
}
|
||||
/// Pick one object, with a command-specific prompt.
|
||||
pub fn single(id: &'static str, name: &'static str, msg: &'static str) -> Self {
|
||||
SelectRun { single: Some(msg), ..SelectRun::new(id, name) }
|
||||
}
|
||||
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
/// Get an object, act on it and ask again, until Enter or Esc: AutoCAD's Layer Off (LAYOFF)
|
||||
/// turns off the layer of every object picked, one after another, in one command.
|
||||
pub fn repeat(id: &'static str, name: &'static str, msg: &'static str) -> Self {
|
||||
SelectRun { single: Some(msg), repeat: true, ..SelectRun::new(id, name) }
|
||||
}
|
||||
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<()> {
|
||||
let spec = super::find_command(self.id).ok_or_else(|| EngineError::UnknownCommand(self.id.into()))?;
|
||||
// Call the command body directly: the running command records the single undo step.
|
||||
let r = (spec.run)(s, &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))?;
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
s.echo(m.to_string());
|
||||
}
|
||||
Ok(Step::Done)
|
||||
Ok(())
|
||||
}
|
||||
/// One pick of a repeating run: act on it at once and keep the prompt up.
|
||||
fn pick(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
if hs.is_empty() {
|
||||
s.echo("0 found");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.run(s, hs)?;
|
||||
// Objects picked at the prompt don't stay selected afterwards.
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,26 +176,37 @@ impl Interactive for SelectRun {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.run(s, &hs);
|
||||
self.run(s, &hs)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.sel.single = self.single.is_some();
|
||||
// A repeating run acts on every pick, so no single pick may finish it.
|
||||
self.sel.single = self.single.is_some() && !self.repeat;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.single {
|
||||
Some(msg) if !self.sel.removing => Prompt::new(msg, Accept::SELECT),
|
||||
// A repeating run keeps asking for the next object, whatever selection keywords were typed.
|
||||
Some(msg) if self.repeat || !self.sel.removing => Prompt::new(msg, Accept::SELECT),
|
||||
_ => self.sel.prompt(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
// A repeating run acts on each pick and prompts again; Enter (or Esc) ends it.
|
||||
if self.repeat {
|
||||
return match i {
|
||||
Input::Pick(hs) => self.pick(s, &hs),
|
||||
Input::Enter => Ok(Step::Done),
|
||||
other => self.sel.feed(s, &other).map(|_| Step::Continue),
|
||||
};
|
||||
}
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
// Objects picked at the prompt don't stay selected afterwards.
|
||||
let step = self.run(s, &hs)?;
|
||||
self.run(s, &hs)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(step)
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod overkill;
|
||||
mod pline_opts;
|
||||
pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
@@ -39,6 +40,8 @@ mod spline_opts;
|
||||
mod table;
|
||||
mod tolerance;
|
||||
mod trimextend;
|
||||
mod txt2mtxt;
|
||||
pub use txt2mtxt::Txt2MtxtOptions;
|
||||
mod utility;
|
||||
mod view;
|
||||
|
||||
@@ -200,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(utility::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(tolerance::specs());
|
||||
v.extend(txt2mtxt::specs());
|
||||
v.extend(constraints::specs());
|
||||
v.extend(lisp::specs());
|
||||
v
|
||||
|
||||
+123
-36
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("join", "Join", run_join)
|
||||
.menu(&["Modify", "Join"])
|
||||
.alias(&["j"])
|
||||
.params("{handles?}")
|
||||
.params("{handles?, close?: bool (a single arc becomes its circle, an elliptical arc its full ellipse)}")
|
||||
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Join)))),
|
||||
]
|
||||
}
|
||||
@@ -421,6 +421,8 @@ impl StretchM {
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let word = t.trim().to_ascii_lowercase();
|
||||
// `_C`, `_L`: the English name, as scripts write it.
|
||||
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
|
||||
if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) {
|
||||
self.mode = Some(m);
|
||||
return Ok(Step::Continue);
|
||||
@@ -584,10 +586,14 @@ pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option<Entit
|
||||
EntityKind::Ellipse(e) => {
|
||||
// Approximate with a polyline offset of the tessellation.
|
||||
let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
|
||||
// Inside = on the centre side of the full ellipse, even for an open arc.
|
||||
let inside = {
|
||||
let mut pts = Vec::new();
|
||||
ge.tessellate(e.major.xy().len() * 1e-3, &mut pts);
|
||||
cadcraft_geom::point_in_polygon(&pts, side)
|
||||
let a = e.major.xy().len();
|
||||
let b = a * e.ratio.abs();
|
||||
let u = e.major.xy().normalized();
|
||||
let d = side - e.center.xy();
|
||||
let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a);
|
||||
x * x + y * y < (a * b) * (a * b)
|
||||
};
|
||||
// offset_points shifts along the left normal, which points into a counter-clockwise ellipse.
|
||||
let mut pts = Vec::new();
|
||||
@@ -1397,6 +1403,16 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
}
|
||||
|
||||
fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
explode_with(s, hs, explode_kind)
|
||||
}
|
||||
|
||||
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
|
||||
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
|
||||
pub(crate) fn explode_with(
|
||||
s: &mut Session,
|
||||
hs: &[Handle],
|
||||
pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option<Vec<Entity>>,
|
||||
) -> Result<Vec<Handle>> {
|
||||
let space = s.space();
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
@@ -1404,7 +1420,7 @@ fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
let Some(parts) = explode_kind(s.doc()?, &e) else { continue };
|
||||
let Some(parts) = pieces(s.doc()?, &e) else { continue };
|
||||
// The pieces of an object are new objects: its xdata goes with it. A block reference's
|
||||
// pieces are copies of the block's objects and keep theirs.
|
||||
let own_pieces = !matches!(e.kind, EntityKind::Insert(_));
|
||||
@@ -1431,6 +1447,16 @@ fn run_explode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation.
|
||||
fn with_z(mut k: EntityKind, z: f64) -> EntityKind {
|
||||
match &mut k {
|
||||
EntityKind::Arc(a) => a.center.z = z,
|
||||
EntityKind::LwPolyline(p) => p.elevation = z,
|
||||
_ => {}
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Break `h` between two points (or at one point when p1 == p2).
|
||||
pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result<Vec<Handle>> {
|
||||
if super::curves::is_locked(s, h) {
|
||||
@@ -1444,12 +1470,13 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
|
||||
if t1 > t2 {
|
||||
std::mem::swap(&mut t1, &mut t2);
|
||||
}
|
||||
let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t);
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(line(ln.a, ln.at(t1)));
|
||||
v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) }));
|
||||
}
|
||||
if t2 < 1.0 - 1e-12 {
|
||||
v.push(line(ln.at(t2), ln.b));
|
||||
v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b }));
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -1460,7 +1487,7 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
|
||||
let a1 = c.center.xy().angle_to(p1);
|
||||
let a2 = c.center.xy().angle_to(p2);
|
||||
// AutoCAD removes the CCW portion from the first to the second point.
|
||||
vec![arc(&Arc::new(c.center.xy(), c.radius, a2, a1))]
|
||||
vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)]
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
@@ -1471,15 +1498,15 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)));
|
||||
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z));
|
||||
}
|
||||
if t2 < ga.sweep() - 1e-12 {
|
||||
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)));
|
||||
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z));
|
||||
}
|
||||
v
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: false };
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
let segs = pl.segments();
|
||||
let total: f64 = segs.iter().map(Segment::len).sum();
|
||||
let lp = |q: Vec2| {
|
||||
@@ -1496,17 +1523,28 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
|
||||
best.1
|
||||
};
|
||||
let (mut t1, mut t2) = (lp(p1), lp(p2));
|
||||
if t1 > t2 {
|
||||
std::mem::swap(&mut t1, &mut t2);
|
||||
if p.closed {
|
||||
if (t1 - t2).abs() < 1e-9 {
|
||||
return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into()));
|
||||
}
|
||||
// Like a circle: the stretch from the first point to the second, along the
|
||||
// vertices, goes; what is left runs from the second point round to the first.
|
||||
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
|
||||
let end = if t1 > t2 { t1 } else { t1 + total };
|
||||
vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)]
|
||||
} else {
|
||||
if t1 > t2 {
|
||||
std::mem::swap(&mut t1, &mut t2);
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation));
|
||||
}
|
||||
if t2 < total - 1e-12 {
|
||||
v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation));
|
||||
}
|
||||
v
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(lwpoly(sub_polyline(&segs, 0.0, t1), false));
|
||||
}
|
||||
if t2 < total - 1e-12 {
|
||||
v.push(lwpoly(sub_polyline(&segs, t2, total), false));
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => return Err(EngineError::Other("Cannot break that object.".into())),
|
||||
};
|
||||
@@ -1624,6 +1662,10 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
|
||||
fn run_join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
if p.get("close").and_then(Value::as_bool) == Some(true) {
|
||||
let h = super::modify2::join_close(s, &hs)?;
|
||||
return Ok(json!({ "handle": h.hex() }));
|
||||
}
|
||||
let r = join(s, &hs)?;
|
||||
Ok(json!({ "handle": r.map(|h| h.hex()) }))
|
||||
}
|
||||
@@ -1660,6 +1702,8 @@ struct SelectThen {
|
||||
ref_len: Option<f64>,
|
||||
copy_mode: bool,
|
||||
ask_erase: bool,
|
||||
/// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose.
|
||||
join_source: Option<Handle>,
|
||||
}
|
||||
|
||||
impl SelectThen {
|
||||
@@ -1676,6 +1720,7 @@ impl SelectThen {
|
||||
ref_len: None,
|
||||
copy_mode: false,
|
||||
ask_erase: false,
|
||||
join_source: None,
|
||||
}
|
||||
}
|
||||
fn apply(&mut self, s: &mut Session) -> Result<Step> {
|
||||
@@ -1691,23 +1736,57 @@ impl SelectThen {
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Op::Join => {
|
||||
match join(s, &self.objs) {
|
||||
Ok(h) => {
|
||||
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
|
||||
// (and the source object an ellipse or spline join keeps).
|
||||
let d = s.doc()?;
|
||||
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
|
||||
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
|
||||
Some(EntityKind::Spline(_)) => "spline",
|
||||
Some(EntityKind::Ellipse(_)) => "ellipse",
|
||||
_ => "polyline",
|
||||
};
|
||||
s.echo(format!("{n} objects joined into 1 {what}"))
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
if let [h] = self.objs[..]
|
||||
&& s.doc()?.entity(h).is_some_and(|e| super::modify2::closed_kind(&e.kind).is_some())
|
||||
{
|
||||
self.join_source = Some(h);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.join_now(s)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
/// JOIN the selected objects and report what they became.
|
||||
fn join_now(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match join(s, &self.objs) {
|
||||
Ok(h) => {
|
||||
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
|
||||
// (and the source object an ellipse or spline join keeps).
|
||||
let d = s.doc()?;
|
||||
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
|
||||
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
|
||||
Some(EntityKind::Spline(_)) => "spline",
|
||||
Some(EntityKind::Ellipse(_)) => "ellipse",
|
||||
_ => "polyline",
|
||||
};
|
||||
s.echo(format!("{n} objects joined into 1 {what}"))
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes
|
||||
/// it its circle or full ellipse.
|
||||
fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "cLose" => {
|
||||
let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)));
|
||||
super::modify2::join_close(s, &[src])?;
|
||||
s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." });
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if let Some(h) = super::curves::pick_at(s, p)
|
||||
&& !self.objs.contains(&h)
|
||||
{
|
||||
self.objs.push(h);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => self.join_now(s),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
@@ -1892,6 +1971,11 @@ impl Interactive for SelectThen {
|
||||
if self.ask_erase {
|
||||
return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
|
||||
}
|
||||
if let Some(src) = self.join_source {
|
||||
let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))));
|
||||
let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" };
|
||||
return Prompt::new(msg, Accept::POINT).kw(&["cLose"]);
|
||||
}
|
||||
let k = self.pts.len();
|
||||
let base = self.pts.first().copied();
|
||||
if self.op == Op::Scale && self.reference {
|
||||
@@ -1939,6 +2023,9 @@ impl Interactive for SelectThen {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(src) = self.join_source {
|
||||
return self.join_source_input(s, src, i);
|
||||
}
|
||||
if self.ask_erase {
|
||||
let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y'));
|
||||
let (a, b) = (self.pts[0], self.pts[1]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, CHSPACE,
|
||||
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, MOCORO, CHSPACE,
|
||||
//! FLATTEN, plus the fillet/trim/extend extensions used by modify.rs (arcs, polylines,
|
||||
//! ellipses and splines).
|
||||
|
||||
@@ -16,7 +16,8 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
|
||||
mod curve_edits;
|
||||
mod fillet_pline;
|
||||
pub(crate) use curve_edits::join_curves;
|
||||
mod mocoro;
|
||||
pub(crate) use curve_edits::{closed_kind, join_close, join_curves};
|
||||
pub(crate) use fillet_pline::{ChamferDistances, fillet_pline};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -61,6 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Modify", "Copy Nested Objects"])
|
||||
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
|
||||
.interactive(|_| Ok(Box::new(NcopyM::default()))),
|
||||
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
|
||||
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
|
||||
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
|
||||
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
|
||||
CommandSpec::new("chspace", "Change Space", run_chspace)
|
||||
.menu(&["Modify", "Change Space"])
|
||||
.params("{handles?, to?: \"model\" | layout name, viewport?: handle}")
|
||||
@@ -761,7 +766,13 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
|
||||
return Err(too_short());
|
||||
}
|
||||
let ne = fixed + dir * nl;
|
||||
Ok(if at_b { line(a, ne) } else { line(ne, b) })
|
||||
let mut out = l.clone();
|
||||
if at_b {
|
||||
out.b = ne.to3(l.b.z);
|
||||
} else {
|
||||
out.a = ne.to3(l.a.z);
|
||||
}
|
||||
Ok(EntityKind::Line(out))
|
||||
}
|
||||
EntityKind::Arc(ar) => {
|
||||
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
|
||||
@@ -782,7 +793,8 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
|
||||
if !(ns > 1e-12 && ns < TAU - 1e-9) {
|
||||
return Err(other("The new arc would be zero length or a full circle."));
|
||||
}
|
||||
Ok(if at_end { arc(&Arc::new(g.center, g.radius, g.start, g.start + ns)) } else { arc(&Arc::new(g.center, g.radius, g.end - ns, g.end)) })
|
||||
let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) };
|
||||
Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() }))
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => {
|
||||
let total = Polyline { vertices: p.vertices.clone(), closed: false }.len();
|
||||
|
||||
@@ -316,6 +316,32 @@ pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handl
|
||||
Ok(Some(keep))
|
||||
}
|
||||
|
||||
/// What JOIN's Close option makes of a single arc (its circle) or elliptical arc (its full
|
||||
/// ellipse); `None` for anything else.
|
||||
pub(crate) fn closed_kind(k: &EntityKind) -> Option<EntityKind> {
|
||||
match k {
|
||||
EntityKind::Arc(a) => Some(EntityKind::Circle(cadcraft_doc::Circle { center: a.center, radius: a.radius })),
|
||||
EntityKind::Ellipse(e) if !ellipse_of(e).is_full() => Some(ellipse_kind(&Ellipse { start: 0.0, end: TAU, ..ellipse_of(e) })),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// JOIN Close: the single arc or elliptical arc in `hs` becomes its circle or full ellipse,
|
||||
/// keeping its handle.
|
||||
pub(crate) fn join_close(s: &mut Session, hs: &[Handle]) -> Result<Handle> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let hs: Vec<Handle> = hs.iter().copied().filter(|h| seen.insert(*h)).collect();
|
||||
let refuse = || other("Close works on a single arc or elliptical arc.");
|
||||
let [h] = hs[..] else { return Err(refuse()) };
|
||||
let k = s.doc()?.entity(h).and_then(|e| closed_kind(&e.kind)).ok_or_else(refuse)?;
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("An object is on a locked layer."));
|
||||
}
|
||||
set_kind(s, h, k)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
// ---------------- FILLET with elliptical arcs and splines ----------------
|
||||
|
||||
/// An entity as a tangency target for FILLET (lines infinite, arcs as their circle, elliptical
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! MOCORO (Express Tools Move/Copy/Rotate): select objects and a base point once, then Move,
|
||||
//! Copy, Rotate, Scale, Base and Undo at one option prompt until eXit.
|
||||
//!
|
||||
//! Copy leaves the selected objects where they are and adds copies: the options after it act on
|
||||
//! the originals, from the same base point. Move carries the base point along, so a later Rotate
|
||||
//! or Scale turns about the same spot on the objects. The whole command is one undo step; Undo
|
||||
//! inside it steps back one option at a time (Base included), up to the last 1000.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::other;
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::helpers::line;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase, number};
|
||||
use crate::cmd::modify::transform_entities;
|
||||
use crate::cmd::{bad, point_req, point_value, targets};
|
||||
use crate::prompt::Drag;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most options one JSON call runs.
|
||||
const MAX_OPS: usize = 10_000;
|
||||
/// Most options Undo can step back through; the oldest are dropped past it.
|
||||
const MAX_HISTORY: usize = 1000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Op {
|
||||
Move(Vec2),
|
||||
Copy(Vec2),
|
||||
/// Radians, counterclockwise.
|
||||
Rotate(f64),
|
||||
Scale(f64),
|
||||
Base(Vec2),
|
||||
}
|
||||
|
||||
fn positive_factor(f: f64) -> Option<f64> {
|
||||
(f.is_finite() && f > 1e-12).then_some(f)
|
||||
}
|
||||
|
||||
/// One `ops` entry: an object with exactly one of move, copy, rotate (degrees), scale or base.
|
||||
fn parse_op(i: usize, v: &Value) -> Result<Op> {
|
||||
let err = |m: &str| bad("mocoro", format!("`ops[{i}]`: {m}"));
|
||||
let Some(o) = v.as_object().filter(|o| o.len() == 1) else {
|
||||
return Err(err("one of {move|copy|base: [x,y]} | {rotate: degrees} | {scale: factor}"));
|
||||
};
|
||||
let Some((k, v)) = o.iter().next() else { return Err(err("empty")) };
|
||||
let point = || point_value(v).ok_or_else(|| err("a point [x, y] is required"));
|
||||
match k.as_str() {
|
||||
"move" => Ok(Op::Move(point()?)),
|
||||
"copy" => Ok(Op::Copy(point()?)),
|
||||
"base" => Ok(Op::Base(point()?)),
|
||||
"rotate" => v.as_f64().filter(|a| a.is_finite()).map(|a| Op::Rotate(a.to_radians())).ok_or_else(|| err("`rotate` is an angle in degrees")),
|
||||
"scale" => v.as_f64().and_then(positive_factor).map(Op::Scale).ok_or_else(|| err("`scale` is a factor above zero")),
|
||||
_ => Err(err("unknown option (move, copy, rotate, scale, base)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many of `objs` are still in the drawing, and how many of those are on a locked layer
|
||||
/// (they don't move, rotate or scale; copies are made).
|
||||
fn counts(s: &Session, objs: &[Handle]) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
let live: Vec<_> = objs.iter().filter_map(|h| d.entity(*h)).collect();
|
||||
let locked = live.iter().filter(|e| d.layer(&e.common.layer).is_some_and(|l| l.locked)).count();
|
||||
Ok((live.len(), locked))
|
||||
}
|
||||
|
||||
fn locked_message(n: usize) -> String {
|
||||
if n == 1 { "1 object was on a locked layer.".into() } else { format!("{n} objects were on a locked layer.") }
|
||||
}
|
||||
|
||||
/// Apply one option to the objects; returns the copies made. The drawing is not touched when
|
||||
/// nothing would change (objects gone or all locked), so no empty undo step is recorded.
|
||||
fn apply(s: &mut Session, objs: &[Handle], base: &mut Vec2, op: Op) -> Result<Vec<Handle>> {
|
||||
let b = *base;
|
||||
let (live, locked) = counts(s, objs)?;
|
||||
let m = match op {
|
||||
Op::Base(p) => {
|
||||
*base = p;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Op::Copy(p) if live > 0 => return transform_entities(s, objs, &Mat3::translate(p - b), true),
|
||||
Op::Copy(_) => return Ok(Vec::new()),
|
||||
Op::Move(p) => Mat3::translate(p - b),
|
||||
Op::Rotate(a) => Mat3::rotate_about(b, a),
|
||||
Op::Scale(f) => Mat3::scale_about(b, f),
|
||||
};
|
||||
if locked > 0 {
|
||||
s.echo(locked_message(locked));
|
||||
}
|
||||
if locked < live {
|
||||
transform_entities(s, objs, &m, false)?;
|
||||
if let Op::Move(p) = op {
|
||||
*base = p;
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let objs = targets(s, p)?;
|
||||
let mut base = point_req("mocoro", p, "base")?;
|
||||
let list = p.get("ops").and_then(Value::as_array).ok_or_else(|| bad("mocoro", "`ops` (array) is required"))?;
|
||||
if list.len() > MAX_OPS {
|
||||
return Err(bad("mocoro", format!("at most {MAX_OPS} `ops`")));
|
||||
}
|
||||
// Every option is checked before the drawing changes.
|
||||
let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::<Result<Vec<_>>>()?;
|
||||
let mut copies = Vec::new();
|
||||
for op in ops {
|
||||
copies.extend(apply(s, &objs, &mut base, op)?);
|
||||
}
|
||||
Ok(json!({ "handles": copies.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// The prompt MOCORO is at.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Mode {
|
||||
#[default]
|
||||
Select,
|
||||
BasePoint,
|
||||
Options,
|
||||
Move,
|
||||
Copy,
|
||||
Rotate,
|
||||
Scale,
|
||||
Base,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MocoroM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
base: Vec2,
|
||||
mode: Mode,
|
||||
/// The drawing and base point before each option that changed either, for Undo.
|
||||
history: Vec<(Arc<Drawing>, Vec2)>,
|
||||
}
|
||||
|
||||
impl MocoroM {
|
||||
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
|
||||
let before = s.state()?.doc.clone();
|
||||
let base = self.base;
|
||||
apply(s, &self.objs, &mut self.base, op)?;
|
||||
if !Arc::ptr_eq(&before, &s.state()?.doc) || self.base != base {
|
||||
if self.history.len() >= MAX_HISTORY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
self.history.push((before, base));
|
||||
}
|
||||
self.mode = Mode::Options;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn undo(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match self.history.pop() {
|
||||
Some((doc, base)) => {
|
||||
// Settings changed meanwhile (a transparent command) stay.
|
||||
let header = s.doc()?.header.clone();
|
||||
s.state_mut()?.doc = doc;
|
||||
if s.doc()?.header != header {
|
||||
s.doc_mut()?.header = header;
|
||||
}
|
||||
self.base = base;
|
||||
}
|
||||
None => s.echo("Nothing to undo."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// The transform under the cursor in an option that drags the objects.
|
||||
fn matrix(&self, c: Vec2) -> Option<Mat3> {
|
||||
let b = self.base;
|
||||
match self.mode {
|
||||
Mode::Move | Mode::Copy => Some(Mat3::translate(c - b)),
|
||||
Mode::Rotate => Some(Mat3::rotate_about(b, b.angle_to(c))),
|
||||
Mode::Scale => positive_factor(b.dist(c)).map(|f| Mat3::scale_about(b, f)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MocoroM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MOCORO"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
self.mode = Mode::BasePoint;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let b = self.base;
|
||||
match self.mode {
|
||||
Mode::Select => self.sel.prompt(),
|
||||
Mode::BasePoint => Prompt::new("Specify base point", Accept::POINT),
|
||||
Mode::Options => Prompt::new("Enter an option", KW).kw(&["Move", "Copy", "Rotate", "Scale", "Base", "Undo", "eXit"]).default("eXit"),
|
||||
Mode::Move | Mode::Copy => Prompt::new("Specify second point", Accept::POINT).base(b),
|
||||
Mode::Rotate => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).base(b),
|
||||
Mode::Scale => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).base(b),
|
||||
Mode::Base => Prompt::new("Specify base point", Accept::POINT).base(b),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.mode, i) {
|
||||
(Mode::Select, i) => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
self.mode = Mode::BasePoint;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
(Mode::BasePoint, Input::Point(p)) => {
|
||||
self.base = p;
|
||||
self.mode = Mode::Options;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Mode::BasePoint, Input::Enter) => {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Mode::Options, Input::Keyword(k)) => {
|
||||
self.mode = match k.as_str() {
|
||||
"Move" => Mode::Move,
|
||||
"Copy" => Mode::Copy,
|
||||
"Rotate" => Mode::Rotate,
|
||||
"Scale" => Mode::Scale,
|
||||
"Base" => Mode::Base,
|
||||
"Undo" => return self.undo(s),
|
||||
_ => {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Mode::Options, Input::Enter) => {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Mode::Options, _) => Err(other("Invalid option keyword.")),
|
||||
// Enter in an option goes back to the option prompt without changing anything.
|
||||
(Mode::Move | Mode::Copy | Mode::Rotate | Mode::Scale | Mode::Base, Input::Enter) => {
|
||||
self.mode = Mode::Options;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Mode::Move, Input::Point(p)) => self.run(s, Op::Move(p)),
|
||||
(Mode::Copy, Input::Point(p)) => self.run(s, Op::Copy(p)),
|
||||
(Mode::Base, Input::Point(p)) => self.run(s, Op::Base(p)),
|
||||
(Mode::Rotate, Input::Point(p)) => self.run(s, Op::Rotate(self.base.angle_to(p))),
|
||||
(Mode::Rotate, Input::Text(t)) => {
|
||||
let a = s.angle_settings().rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle or point."))?;
|
||||
self.run(s, Op::Rotate(a))
|
||||
}
|
||||
(Mode::Scale, Input::Point(p)) => {
|
||||
let f = positive_factor(self.base.dist(p)).ok_or_else(|| other("Requires a positive number."))?;
|
||||
self.run(s, Op::Scale(f))
|
||||
}
|
||||
(Mode::Scale, Input::Text(t)) => {
|
||||
let f = number(&t).and_then(positive_factor).ok_or_else(|| other("Requires a positive number."))?;
|
||||
self.run(s, Op::Scale(f))
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let (Some(m), Ok(d)) = (self.matrix(c), s.doc()) else { return Vec::new() };
|
||||
let mut out: Vec<EntityKind> = self
|
||||
.objs
|
||||
.iter()
|
||||
.take(500)
|
||||
.filter_map(|h| d.entity(*h))
|
||||
.map(|e| {
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
out.push(line(self.base, c));
|
||||
out
|
||||
}
|
||||
fn drag(&self, _s: &Session, c: Vec2) -> Option<Drag> {
|
||||
let matrix = self.matrix(c)?;
|
||||
Some(Drag { handles: self.objs.clone(), matrix, extra: vec![line(self.base, c)], clipboard: false })
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,39 @@ fn lengthen_json() {
|
||||
assert!(near(poly(&kind(&s, &p)).vertices[0].p, -5.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_keeps_elevation() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 90})).unwrap());
|
||||
let doc = s.doc_mut().unwrap();
|
||||
doc.modify_entity(Handle::parse_hex(&l).unwrap(), |e| {
|
||||
if let EntityKind::Line(x) = &mut e.kind {
|
||||
x.a.z = 5.0;
|
||||
x.b.z = 5.0;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
doc.modify_entity(Handle::parse_hex(&a).unwrap(), |e| {
|
||||
if let EntityKind::Arc(x) = &mut e.kind {
|
||||
x.center.z = 3.0;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("lengthen", &json!({"handle": l, "delta": 2})).unwrap();
|
||||
let EntityKind::Line(x) = kind(&s, &l) else { panic!("not a line") };
|
||||
assert_eq!((x.a.x, x.a.y, x.a.z), (0.0, 0.0, 5.0));
|
||||
assert_eq!((x.b.x, x.b.y, x.b.z), (12.0, 0.0, 5.0));
|
||||
s.execute("lengthen", &json!({"handle": l, "delta": 1, "pick": [0, 0]})).unwrap();
|
||||
let EntityKind::Line(x) = kind(&s, &l) else { panic!("not a line") };
|
||||
assert_eq!((x.a.x, x.a.y, x.a.z), (-1.0, 0.0, 5.0));
|
||||
assert_eq!((x.b.x, x.b.y, x.b.z), (12.0, 0.0, 5.0));
|
||||
s.execute("lengthen", &json!({"handle": a, "deltaAngle": 10})).unwrap();
|
||||
let EntityKind::Arc(x) = kind(&s, &a) else { panic!("not an arc") };
|
||||
assert_eq!(x.center.z, 3.0);
|
||||
assert!((arc_of(&kind(&s, &a)).sweep() - 100f64.to_radians()).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_command_line() {
|
||||
let mut s = Session::new();
|
||||
@@ -721,3 +754,18 @@ fn offset_polyline_arc_bulge_follows_joined_endpoints() {
|
||||
assert!(near(arc.center, 10.0, 5.0), "arc centre drifted to {:?}", arc.center);
|
||||
assert!((arc.radius - 4.0).abs() < 1e-6, "radius {}", arc.radius);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_elliptical_arc_follows_picked_side() {
|
||||
let max_x = |side: Value| {
|
||||
let mut s = Session::new();
|
||||
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [10, 0], "ratio": 0.5, "start": 0, "end": 90})).unwrap());
|
||||
s.execute("offset", &json!({"handle": e, "distance": 1, "side": side})).unwrap();
|
||||
let p = poly(&last(&s));
|
||||
p.vertices.iter().map(|v| v.p.x).fold(f64::MIN, f64::max)
|
||||
};
|
||||
let centre = max_x(json!([0, 0]));
|
||||
assert!((centre - 9.0).abs() < 0.05, "centre side went outward: max x = {centre}");
|
||||
let outer = max_x(json!([20, 20]));
|
||||
assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}");
|
||||
}
|
||||
@@ -50,6 +50,7 @@ pub const PROFILE_VARS: &[&str] = &[
|
||||
"CURSORSIZE",
|
||||
"PICKFIRST",
|
||||
"PICKADD",
|
||||
"PICKDRAG",
|
||||
"MAXARRAY",
|
||||
];
|
||||
|
||||
@@ -61,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("options", "Options...", run_options)
|
||||
.menu(&["Tools", "Options..."])
|
||||
.alias(&["op", "preferences"])
|
||||
.params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, …: any profile variable}")
|
||||
.params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, PICKDRAG?: 0 (two clicks) | 2 (clicks or drag), …: any profile variable}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
]
|
||||
@@ -141,6 +142,7 @@ pub fn reset(s: &mut Session) {
|
||||
st.cursorsize = d.cursorsize;
|
||||
st.pickfirst = d.pickfirst;
|
||||
st.pickadd = d.pickadd;
|
||||
st.pickdrag = d.pickdrag;
|
||||
st.maxarray = d.maxarray;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,19 +90,19 @@ impl PlineAsk {
|
||||
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::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st).distance(),
|
||||
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::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st).distance(),
|
||||
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),
|
||||
PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st).distance(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ use cadcraft_geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>}"),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
|
||||
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
|
||||
.menu(&["Modify", "Match Properties"])
|
||||
.alias(&["ma", "painter"])
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.noundo(),
|
||||
CommandSpec::new("-linetype", "Linetype", run_linetype).params("{current?, load?, file? | text?, reload?} (as LINETYPE)").interactive(|_| Ok(Box::new(linfile::DashLinetype::new()))),
|
||||
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
|
||||
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
|
||||
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
|
||||
CommandSpec::new("style", "Text Style...", run_style)
|
||||
@@ -203,6 +203,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dim_style = str_param(p, "dimStyle")
|
||||
.map(|n| d.dim_style(n).map(|st| st.name.clone()).ok_or_else(|| bad("properties.set", format!("dimension style `{n}` not found"))))
|
||||
.transpose()?;
|
||||
let dim_over = dim_overrides(p)?;
|
||||
let anno_scale = d.current_anno_scale();
|
||||
for h in &hs {
|
||||
d.modify_entity(*h, |e| {
|
||||
@@ -374,6 +375,10 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
dm.style.clone_from(st);
|
||||
dm.block = None;
|
||||
}
|
||||
if !dim_over.is_empty() {
|
||||
dm.overrides.extend(dim_over.clone());
|
||||
dm.block = None;
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => {
|
||||
if let Some(v) = str_param(p, "pattern") {
|
||||
@@ -471,6 +476,39 @@ fn run_ltscale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": "Regenerating model." }))
|
||||
}
|
||||
|
||||
/// Typed LTSCALE: "Enter new linetype scale factor <1.0000>:".
|
||||
struct LtscaleM;
|
||||
|
||||
impl Interactive for LtscaleM {
|
||||
fn name(&self) -> &'static str {
|
||||
"LTSCALE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = s.doc().map(|d| d.header.f64("LTSCALE", 1.0)).unwrap_or(1.0);
|
||||
Prompt::new("Enter new linetype scale factor", Accept::NUMBER).default(format!("{cur:.4}"))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match i {
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
Input::Text(t) => t,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let scale = text.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0);
|
||||
match scale.map(|v| run_ltscale(s, &json!({ "scale": v }))) {
|
||||
Some(Ok(r)) => {
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
s.echo(m.to_string());
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => {
|
||||
s.echo("Requires a positive number.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
|
||||
@@ -831,6 +869,26 @@ fn run_dimstyle_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(dimstyles_json(d))
|
||||
}
|
||||
|
||||
/// A dimension's text height, arrow size, overall scale (DIMSCALE) and precision as Properties
|
||||
/// sets them: per-dimension overrides, as in AutoCAD's Properties palette.
|
||||
fn dim_overrides(p: &Value) -> Result<serde_json::Map<String, Value>> {
|
||||
let mut o = serde_json::Map::new();
|
||||
for (key, field, zero_ok) in [("textHeight", "textHeight", false), ("arrowSize", "arrowSize", true), ("dimScale", "scale", false)] {
|
||||
if let Some(v) = p.get(key).filter(|v| !v.is_null()) {
|
||||
let x = v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && (*x > 0.0 || (zero_ok && *x == 0.0)))
|
||||
.ok_or_else(|| bad("properties.set", format!("`{key}` must be a positive number")))?;
|
||||
o.insert(field.into(), json!(x));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("precision").filter(|v| !v.is_null()) {
|
||||
let n = v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad("properties.set", "`precision` must be 0 to 8 decimal places"))?;
|
||||
o.insert("decimals".into(), json!(n));
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// Per-dimension style overrides (DIMOVERRIDE): merge fields, or `clear` them.
|
||||
pub(super) fn dim_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
|
||||
@@ -224,16 +224,27 @@ fn separator(path: &str, text: &str) -> char {
|
||||
if first.contains(';') && !first.contains(',') { ';' } else { ',' }
|
||||
}
|
||||
|
||||
/// The file to read for a data link: a path the user just `chosen` as given, a path stored in the
|
||||
/// drawing at `host` only through [`crate::doc_path::resolve`].
|
||||
fn link_file(id: &str, path: &str, host: Option<&str>, chosen: bool) -> Result<std::path::PathBuf> {
|
||||
if chosen {
|
||||
return Ok(std::path::PathBuf::from(path));
|
||||
}
|
||||
crate::doc_path::resolve(path, host.map(std::path::Path::new)).map_err(|r| bad(id, format!("{path}: not opened, {r}")))
|
||||
}
|
||||
|
||||
/// Read a linked file through the host (natively the file system; not on the web).
|
||||
fn read_link(id: &str, path: &str) -> Result<String> {
|
||||
fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -278,14 +289,16 @@ fn fill(t: &mut Table, start: usize, data: &[Vec<String>]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the linked file of table `h`.
|
||||
fn update(s: &mut Session, h: Handle) -> Result<(usize, usize)> {
|
||||
/// Re-read the linked file of table `h`; `chosen` says its path was just given by the user.
|
||||
fn update(s: &mut Session, h: Handle, chosen: bool) -> Result<(usize, usize)> {
|
||||
let id = "datalinkupdate";
|
||||
let link = match s.doc()?.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Table(t)) => t.data_link.clone().ok_or_else(|| bad(id, "the table has no data link"))?,
|
||||
_ => return Err(bad(id, "object is not a table")),
|
||||
};
|
||||
let text = read_link(id, &link.path)?;
|
||||
let host = s.state()?.path.clone();
|
||||
let file = link_file(id, &link.path, host.as_deref(), chosen)?;
|
||||
let text = read_link(id, &file)?;
|
||||
let data = parse_csv(&text, separator(&link.path, &text));
|
||||
let mut size = (0, 0);
|
||||
edit(s, &json!({ "handle": h.hex() }), id, |t| {
|
||||
@@ -313,7 +326,7 @@ fn run_datalink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})?;
|
||||
let h = r.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex).ok_or_else(|| bad(id, "no table"))?;
|
||||
let (rows, cols) = update(s, h)?;
|
||||
let (rows, cols) = update(s, h, true)?;
|
||||
Ok(json!({ "handle": h.hex(), "rows": rows, "cols": cols }))
|
||||
}
|
||||
|
||||
@@ -340,7 +353,7 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut updated = 0;
|
||||
let mut errors = Vec::new();
|
||||
for h in hs {
|
||||
match update(s, h) {
|
||||
match update(s, h, false) {
|
||||
Ok(_) => updated += 1,
|
||||
Err(e) => errors.push(e.to_string()),
|
||||
}
|
||||
@@ -369,4 +382,18 @@ mod tests {
|
||||
assert_eq!(separator("a.tsv", "x,y"), '\t');
|
||||
assert_eq!(separator("a.csv", "x;y"), ';');
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn link_strips_utf8_bom() {
|
||||
let path = std::env::temp_dir().join(format!("cadcraft_bom_link_{}.csv", std::process::id()));
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(b"\"Item, name\",Qty\r\nBolt,4\r\n");
|
||||
std::fs::write(&path, bytes).expect("write temp csv");
|
||||
let text = read_link("datalink", &path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let text = text.expect("read link");
|
||||
let rows = parse_csv(&text, separator("bom.csv", &text));
|
||||
assert_eq!(rows, vec![vec!["Item, name", "Qty"], vec!["Bolt", "4"]]);
|
||||
}
|
||||
}
|
||||
@@ -646,6 +646,20 @@ pub enum Shift {
|
||||
DeleteCol(usize),
|
||||
}
|
||||
|
||||
/// The `lo..=hi` extent of a range after inserting a row/column at `at`.
|
||||
fn shift_span(lo: usize, hi: usize, at: usize) -> (usize, usize) {
|
||||
(if lo >= at { lo + 1 } else { lo }, if hi >= at { hi + 1 } else { hi })
|
||||
}
|
||||
|
||||
/// The `lo..=hi` extent of a range after deleting row/column `at` (the start stays, the next
|
||||
/// row moves up into it; the end moves up); `None` when the range was only that row/column.
|
||||
fn shift_span_delete(lo: usize, hi: usize, at: usize) -> Option<(usize, usize)> {
|
||||
if lo == at && hi == at {
|
||||
return None;
|
||||
}
|
||||
Some((if lo > at { lo - 1 } else { lo }, if hi >= at { hi.checked_sub(1)? } else { hi }))
|
||||
}
|
||||
|
||||
/// `formula` with its references moved for a row/column insertion or deletion; references to a
|
||||
/// deleted row/column become `#REF` (range corners move inward instead).
|
||||
pub fn shift_refs(formula: &str, shift: Shift) -> String {
|
||||
@@ -655,24 +669,42 @@ pub fn shift_refs(formula: &str, shift: Shift) -> String {
|
||||
let body = src.get(body_start..).unwrap_or("");
|
||||
let mut out = String::from(src.get(..body_start).unwrap_or(""));
|
||||
let mut last = 0;
|
||||
let mut skip_to = 0;
|
||||
for (k, (tok, span)) in toks.iter().enumerate() {
|
||||
if k < skip_to {
|
||||
continue;
|
||||
}
|
||||
let Tok::Cell(r, c) = *tok else { continue };
|
||||
let range_start = toks.get(k + 1).is_some_and(|t| t.0 == Tok::Op(':'));
|
||||
let range_end = k > 0 && toks.get(k - 1).is_some_and(|t| t.0 == Tok::Op(':'));
|
||||
// `A5:A2` is the same range as `A2:A5`: move the normalised corners as one unit.
|
||||
if let (Some((Tok::Op(':'), _)), Some((Tok::Cell(r2, c2), end))) = (toks.get(k + 1), toks.get(k + 2)) {
|
||||
skip_to = k + 3;
|
||||
let (rlo, rhi, clo, chi) = (r.min(*r2), r.max(*r2), c.min(*c2), c.max(*c2));
|
||||
let moved = match shift {
|
||||
Shift::InsertRow(at) => Some((shift_span(rlo, rhi, at), (clo, chi))),
|
||||
Shift::DeleteRow(at) => shift_span_delete(rlo, rhi, at).map(|rr| (rr, (clo, chi))),
|
||||
Shift::InsertCol(at) => Some(((rlo, rhi), shift_span(clo, chi, at))),
|
||||
Shift::DeleteCol(at) => shift_span_delete(clo, chi, at).map(|cc| ((rlo, rhi), cc)),
|
||||
};
|
||||
out.push_str(body.get(last..span.start).unwrap_or(""));
|
||||
match moved {
|
||||
Some(((r0, r1), (c0, c1))) if (r0, r1, c0, c1) == (rlo, rhi, clo, chi) => {
|
||||
out.push_str(body.get(span.start..end.end).unwrap_or(""));
|
||||
}
|
||||
Some(((r0, r1), (c0, c1))) => {
|
||||
out.push_str(&format!("{}:{}", cell_name(r0, c0), cell_name(r1, c1)));
|
||||
}
|
||||
None => out.push_str("#REF"),
|
||||
}
|
||||
last = end.end;
|
||||
continue;
|
||||
}
|
||||
let moved = |v: usize, at: usize, insert: bool| -> Option<usize> {
|
||||
if insert {
|
||||
Some(if v >= at { v + 1 } else { v })
|
||||
} else if v > at {
|
||||
Some(v - 1)
|
||||
} else if v == at {
|
||||
// A range's start stays (the next row moves up into it); its end moves up.
|
||||
if range_start {
|
||||
Some(v)
|
||||
} else if range_end {
|
||||
v.checked_sub(1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
None
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
@@ -728,5 +760,13 @@ mod tests {
|
||||
assert_eq!(shift_refs("=Sum(A2:A5)", Shift::DeleteRow(4)), "=Sum(A2:A4)");
|
||||
assert_eq!(shift_refs("=B2*2", Shift::DeleteCol(1)), "=#REF*2");
|
||||
assert_eq!(shift_refs("=C1", Shift::InsertCol(0)), "=D1");
|
||||
assert_eq!(shift_refs("=Sum(A3:A3)", Shift::DeleteRow(2)), "=Sum(#REF)");
|
||||
assert_eq!(shift_refs("=Sum(A3:C3)", Shift::DeleteCol(1)), "=Sum(A3:B3)");
|
||||
assert_eq!(shift_refs("=Sum(B3:B3)", Shift::DeleteCol(1)), "=Sum(#REF)");
|
||||
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::DeleteRow(1)), "=Sum(A2:A4)");
|
||||
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::DeleteRow(4)), "=Sum(A2:A4)");
|
||||
assert_eq!(shift_refs("=Sum(C1:A1)", Shift::DeleteCol(0)), "=Sum(A1:B1)");
|
||||
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::InsertRow(2)), "=Sum(A2:A6)");
|
||||
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::InsertRow(9)), "=Sum(A5:A2)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! TXT2MTXT (Express Tools): single-line TEXT, and MTEXT when combining, turned into MTEXT.
|
||||
//!
|
||||
//! Combined, the texts become one MTEXT in place of the first of them (its handle, layer and
|
||||
//! other properties kept), one line each, from the top down or in selection order, at the top
|
||||
//! left of their extents. Otherwise each TEXT becomes its own MTEXT, attached at the point it
|
||||
//! is justified by.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, Handle, MText, Text, VAlign, entity_bounds, mtext_literal};
|
||||
use cadcraft_geom::Bounds2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves::KW;
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::{CommandSpec, bad, bool_or, str_param, targets};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
|
||||
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
|
||||
.params(
|
||||
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
|
||||
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
|
||||
)
|
||||
.interactive(|s| Ok(Box::new(Txt2MtxtM::new(s)))),
|
||||
]
|
||||
}
|
||||
|
||||
/// TXT2MTXT's settings: one MTEXT or one per TEXT, the order of the lines and word wrap.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Txt2MtxtOptions {
|
||||
pub combine: bool,
|
||||
pub top_down: bool,
|
||||
pub wrap: bool,
|
||||
}
|
||||
|
||||
impl Default for Txt2MtxtOptions {
|
||||
fn default() -> Self {
|
||||
Txt2MtxtOptions { combine: true, top_down: true, wrap: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the TEXT (and, combining, MTEXT) objects among `hs`; objects on locked layers are
|
||||
/// left alone. Returns the MTEXT objects made and how many texts were on a locked layer.
|
||||
fn convert(s: &mut Session, hs: &[Handle], o: &Txt2MtxtOptions) -> Result<(Vec<Handle>, usize)> {
|
||||
let d = s.doc()?;
|
||||
let mut locked = 0;
|
||||
let mut items: Vec<(Handle, EntityKind, Bounds2)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(*h) else { continue };
|
||||
let wanted = match &e.kind {
|
||||
EntityKind::Text(_) => true,
|
||||
EntityKind::MText(_) => o.combine,
|
||||
_ => false,
|
||||
};
|
||||
if !wanted || !seen.insert(*h) {
|
||||
continue;
|
||||
}
|
||||
if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
locked += 1;
|
||||
continue;
|
||||
}
|
||||
items.push((*h, e.kind.clone(), entity_bounds(d, e, 0)));
|
||||
}
|
||||
if !o.combine {
|
||||
let mut made = Vec::new();
|
||||
for (h, k, _) in items {
|
||||
if let EntityKind::Text(t) = k {
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::MText(single(&t)))?;
|
||||
made.push(h);
|
||||
}
|
||||
}
|
||||
return Ok((made, locked));
|
||||
}
|
||||
if o.top_down {
|
||||
// Highest first; a line shared by several texts reads left to right.
|
||||
items.sort_by(|a, b| {
|
||||
let (pa, pb) = (insertion(&a.1), insertion(&b.1));
|
||||
pb.y.total_cmp(&pa.y).then(pa.x.total_cmp(&pb.x))
|
||||
});
|
||||
}
|
||||
// A lone MTEXT is already what TXT2MTXT would make of it.
|
||||
if let [(_, EntityKind::MText(_), _)] = &items[..] {
|
||||
return Ok((Vec::new(), locked));
|
||||
}
|
||||
let Some((first, k0, _)) = items.first() else { return Ok((Vec::new(), locked)) };
|
||||
let (height, style, rotation) = match k0 {
|
||||
EntityKind::Text(t) => (t.height, t.style.clone(), t.rotation),
|
||||
EntityKind::MText(m) => (m.height, m.style.clone(), m.rotation),
|
||||
_ => return Ok((Vec::new(), locked)),
|
||||
};
|
||||
let ext = items.iter().fold(Bounds2::EMPTY, |b, (.., x)| b.union(x));
|
||||
let corner = if ext.is_empty() { insertion(k0) } else { cadcraft_geom::Vec2::new(ext.min.x, ext.max.y) };
|
||||
let sep = if o.wrap { " " } else { "\\P" };
|
||||
let contents = items.iter().map(|(_, k, _)| contents(k, height)).collect::<Vec<_>>().join(sep);
|
||||
let width = if o.wrap { items.iter().map(|(_, k, _)| line_width(k)).fold(0.0, f64::max) } else { 0.0 };
|
||||
let m = MText {
|
||||
insert: v3(corner),
|
||||
height,
|
||||
width,
|
||||
attach: 1,
|
||||
rotation,
|
||||
style,
|
||||
contents,
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
let first = *first;
|
||||
let rest: Vec<Handle> = items.iter().skip(1).map(|(h, ..)| *h).collect();
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(first, |e| e.kind = EntityKind::MText(m))?;
|
||||
for h in rest {
|
||||
d.remove_entity(h);
|
||||
}
|
||||
Ok((vec![first], locked))
|
||||
}
|
||||
|
||||
/// The point a TEXT or MTEXT is sorted by: its insertion point.
|
||||
fn insertion(k: &EntityKind) -> cadcraft_geom::Vec2 {
|
||||
match k {
|
||||
EntityKind::Text(t) => t.insert.xy(),
|
||||
EntityKind::MText(m) => m.insert.xy(),
|
||||
_ => cadcraft_geom::Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text's lines as MTEXT contents of base height `base`: the TEXT's characters read literally
|
||||
/// (its width factor and oblique angle as inline codes), an MTEXT's contents as they are; either
|
||||
/// keeps its own height with an absolute `\H` code when that differs from `base`.
|
||||
fn contents(k: &EntityKind, base: f64) -> String {
|
||||
match k {
|
||||
EntityKind::Text(t) => text_contents(t, base),
|
||||
EntityKind::MText(m) if differs(m.height, base) => format!("{{\\H{};{}}}", m.height, m.contents),
|
||||
EntityKind::MText(m) => m.contents.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a line of height `h` needs its own height code in an MTEXT of height `base`.
|
||||
fn differs(h: f64, base: f64) -> bool {
|
||||
h.is_finite() && h > 0.0 && (h - base).abs() > base.abs() * 1e-9
|
||||
}
|
||||
|
||||
fn text_contents(t: &Text, base: f64) -> String {
|
||||
let mut codes = String::new();
|
||||
if differs(t.height, base) {
|
||||
codes.push_str(&format!("\\H{};", t.height));
|
||||
}
|
||||
if t.width_factor.is_finite() && t.width_factor > 0.0 && (t.width_factor - 1.0).abs() > 1e-9 {
|
||||
codes.push_str(&format!("\\W{};", t.width_factor));
|
||||
}
|
||||
if t.oblique.is_finite() && t.oblique.abs() > 1e-9 {
|
||||
codes.push_str(&format!("\\Q{};", t.oblique.to_degrees()));
|
||||
}
|
||||
let lit = mtext_literal(&t.value);
|
||||
if codes.is_empty() { lit } else { format!("{{{codes}{lit}}}") }
|
||||
}
|
||||
|
||||
/// How wide a text's longest line is in the drafting font: word wrap's MTEXT width.
|
||||
fn line_width(k: &EntityKind) -> f64 {
|
||||
let w = match k {
|
||||
EntityKind::Text(t) => cadcraft_fonts::line_width(&t.value, t.height, t.width_factor),
|
||||
EntityKind::MText(m) if m.width > 0.0 => m.width,
|
||||
EntityKind::MText(m) => {
|
||||
cadcraft_fonts::plain_mtext(&m.contents).lines().map(|l| cadcraft_fonts::line_width(l, m.height, 1.0)).fold(0.0, f64::max)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
if w.is_finite() { w } else { 0.0 }
|
||||
}
|
||||
|
||||
/// One TEXT as an MTEXT of its own, attached where the TEXT is justified (a baseline as the
|
||||
/// bottom).
|
||||
fn single(t: &Text) -> MText {
|
||||
let col = match t.halign {
|
||||
HAlign::Left | HAlign::Aligned | HAlign::Fit => 0,
|
||||
HAlign::Center | HAlign::Middle => 1,
|
||||
HAlign::Right => 2,
|
||||
};
|
||||
let row = match (t.halign, t.valign) {
|
||||
(HAlign::Middle, _) | (_, VAlign::Middle) => 1,
|
||||
(_, VAlign::Top) => 0,
|
||||
_ => 2,
|
||||
};
|
||||
MText {
|
||||
insert: v3(t.justify_point()),
|
||||
height: t.height,
|
||||
width: 0.0,
|
||||
attach: row * 3 + col + 1,
|
||||
rotation: t.rotation,
|
||||
style: t.style.clone(),
|
||||
contents: text_contents(t, t.height),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_note(n: usize) -> Option<String> {
|
||||
(n > 0).then(|| format!("{n} object(s) on a locked layer."))
|
||||
}
|
||||
|
||||
fn run_txt2mtxt(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let top_down = match str_param(p, "sort") {
|
||||
None | Some("topdown") => true,
|
||||
Some("selection") => false,
|
||||
Some(v) => return Err(bad("txt2mtxt", format!("`sort` must be \"topdown\" or \"selection\", got \"{v}\""))),
|
||||
};
|
||||
let o = Txt2MtxtOptions { combine: bool_or(p, "combine", true), top_down, wrap: bool_or(p, "wrap", false) };
|
||||
let (made, locked) = convert(s, &hs, &o)?;
|
||||
if made.is_empty() {
|
||||
let why = locked_note(locked).unwrap_or_else(|| "No text objects selected.".into());
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
let mut r = json!({ "handles": made.iter().map(|h| h.hex()).collect::<Vec<_>>() });
|
||||
if let Some(m) = locked_note(locked) {
|
||||
r["message"] = json!(m);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Ask {
|
||||
Select,
|
||||
Combine,
|
||||
Sort,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
struct Txt2MtxtM {
|
||||
opts: Txt2MtxtOptions,
|
||||
ask: Ask,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Txt2MtxtM {
|
||||
fn new(s: &Session) -> Self {
|
||||
Txt2MtxtM { opts: s.last_used.txt2mtxt.clone(), ask: Ask::Select, sel: SelectPhase::default() }
|
||||
}
|
||||
fn finish(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
let (made, locked) = convert(s, hs, &self.opts)?;
|
||||
if let Some(m) = locked_note(locked) {
|
||||
s.echo(m);
|
||||
}
|
||||
if made.is_empty() {
|
||||
s.echo("No text objects selected.");
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(b: bool) -> &'static str {
|
||||
if b { "Yes" } else { "No" }
|
||||
}
|
||||
|
||||
impl Interactive for Txt2MtxtM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TXT2MTXT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let o = &self.opts;
|
||||
s.echo(format!("Current settings: Combine= {}, Sort top-down= {}, Word-wrap= {}", yes_no(o.combine), yes_no(o.top_down), yes_no(o.wrap)));
|
||||
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.ask {
|
||||
Ask::Select if self.sel.removing => self.sel.prompt(),
|
||||
Ask::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["SEttings"]),
|
||||
Ask::Combine => Prompt::new("Combine text into a single MTEXT?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.combine)),
|
||||
Ask::Sort => Prompt::new("Sort text top-down?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.top_down)),
|
||||
Ask::Wrap => Prompt::new("Word-wrap text?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.wrap)),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let k = match &i {
|
||||
Input::Keyword(k) => Some(k.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
match self.ask {
|
||||
Ask::Select => {
|
||||
if k == Some("SEttings") {
|
||||
self.ask = Ask::Combine;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.finish(s, &hs),
|
||||
};
|
||||
}
|
||||
Ask::Combine => {
|
||||
match k {
|
||||
Some("Yes") => self.opts.combine = true,
|
||||
Some("No") => self.opts.combine = false,
|
||||
_ if i == Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
// Sorting and word wrap only apply to text combined into one MTEXT.
|
||||
self.ask = if self.opts.combine { Ask::Sort } else { Ask::Select };
|
||||
}
|
||||
Ask::Sort => {
|
||||
match k {
|
||||
Some("Yes") => self.opts.top_down = true,
|
||||
Some("No") => self.opts.top_down = false,
|
||||
_ if i == Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.ask = Ask::Wrap;
|
||||
}
|
||||
Ask::Wrap => {
|
||||
match k {
|
||||
Some("Yes") => self.opts.wrap = true,
|
||||
Some("No") => self.opts.wrap = false,
|
||||
_ if i == Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.ask = Ask::Select;
|
||||
}
|
||||
}
|
||||
s.last_used.txt2mtxt = self.opts.clone();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "txt2mtxt_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,166 @@
|
||||
use cadcraft_doc::{EntityKind, MText};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Three TEXT lines made out of order: "B" lowest, "A" highest. The highest is on layer TOP with
|
||||
/// height 2.5, the others on layer 0 with height 1, so the first text's properties are told apart.
|
||||
fn three_lines(s: &mut Session) -> Vec<String> {
|
||||
s.execute("layer.new", &json!({"name": "TOP"})).unwrap();
|
||||
let h = |r: serde_json::Value| r["handle"].as_str().unwrap().to_string();
|
||||
let b = h(s.execute("text", &json!({"at": [0, 0], "text": "B", "height": 1})).unwrap());
|
||||
s.execute("layer.current", &json!({"name": "TOP"})).unwrap();
|
||||
let a = h(s.execute("text", &json!({"at": [2, 10], "text": "A", "height": 2.5})).unwrap());
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
let c = h(s.execute("text", &json!({"at": [1, 5], "text": "C{1}", "height": 1})).unwrap());
|
||||
vec![b, a, c]
|
||||
}
|
||||
|
||||
fn handles(hs: &[String]) -> Vec<cadcraft_doc::Handle> {
|
||||
hs.iter().map(|h| cadcraft_doc::Handle::parse_hex(h).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn mtexts(s: &Session) -> Vec<(String, MText)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::MText(m) => Some((e.common.layer.clone(), m.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combines_texts_top_down_into_one_mtext_with_the_first_ones_properties() {
|
||||
let mut s = Session::new();
|
||||
let hs = three_lines(&mut s);
|
||||
let r = s.execute("txt2mtxt", &json!({"handles": hs})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.model.len(), 1, "the three texts became one object");
|
||||
let ms = mtexts(&s);
|
||||
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
// Highest first whatever the selection order; braces are literal characters; lines of
|
||||
// another height than the first keep theirs.
|
||||
assert_eq!(m.contents, "A\\P{\\H1;C\\{1\\}}\\P{\\H1;B}");
|
||||
assert_eq!((layer.as_str(), m.height, m.attach, m.width), ("TOP", 2.5, 1, 0.0));
|
||||
// Top left of the texts: the leftmost start point, the top of the highest text.
|
||||
assert_eq!((m.insert.x, m.insert.y), (0.0, 12.5));
|
||||
assert_eq!(r["handles"], json!([hs[1]]), "the highest text's object holds the MTEXT");
|
||||
|
||||
s.undo().unwrap();
|
||||
let kinds: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect();
|
||||
assert_eq!(kinds, ["Text", "Text", "Text"], "one undo step brings the three texts back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_order_wrap_and_one_mtext_per_text() {
|
||||
let mut s = Session::new();
|
||||
let hs = three_lines(&mut s);
|
||||
s.execute("txt2mtxt", &json!({"handles": hs, "sort": "selection", "wrap": true})).unwrap();
|
||||
let ms = mtexts(&s);
|
||||
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
assert_eq!(m.contents, "B {\\H2.5;A} C\\{1\\}", "selection order, one wrapped paragraph");
|
||||
assert_eq!((layer.as_str(), m.height), ("0", 1.0), "the first selected text's properties");
|
||||
assert!(m.width > 0.0, "word wrap gives the MTEXT a width");
|
||||
s.undo().unwrap();
|
||||
|
||||
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
|
||||
let ms = mtexts(&s);
|
||||
assert_eq!(ms.len(), 3, "one MTEXT per TEXT: {ms:?}");
|
||||
let a = ms.iter().find(|(_, m)| m.contents == "A").unwrap();
|
||||
// A baseline-left TEXT is attached bottom left at its start point, keeping layer and height.
|
||||
assert_eq!((a.0.as_str(), a.1.height, a.1.attach, a.1.insert.x, a.1.insert.y), ("TOP", 2.5, 7, 2.0, 10.0));
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_settings_and_locked_layers() {
|
||||
let mut s = Session::new();
|
||||
let hs = three_lines(&mut s);
|
||||
let steps = s.state().unwrap().undo.len();
|
||||
s.start("txt2mtxt").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects or [SEttings]"), "{}", s.prompt_text());
|
||||
s.cmdline("se").unwrap();
|
||||
assert!(s.prompt_text().contains("Combine text into a single MTEXT? [Yes/No] <Yes>"), "{}", s.prompt_text());
|
||||
s.cmdline("n").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "sorting and wrapping are skipped: {}", s.prompt_text());
|
||||
s.input(crate::Input::Pick(handles(&hs))).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(mtexts(&s).len(), 3);
|
||||
assert_eq!(s.state().unwrap().undo.len(), steps + 1, "one undo step");
|
||||
s.undo().unwrap();
|
||||
|
||||
// The setting is remembered; a text on a locked layer is left alone.
|
||||
s.execute("layer.set", &json!({"name": "TOP", "locked": true})).unwrap();
|
||||
s.start("txt2mtxt").unwrap();
|
||||
s.cmdline("se").unwrap();
|
||||
assert!(s.prompt_text().contains("<No>"), "{}", s.prompt_text());
|
||||
s.cmdline("y").unwrap();
|
||||
assert!(s.prompt_text().contains("Sort text top-down? [Yes/No] <Yes>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("Word-wrap text? [Yes/No] <No>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
s.input(crate::Input::Pick(handles(&hs))).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let ms = mtexts(&s);
|
||||
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
assert_eq!(m.contents, "C\\{1\\}\\PB");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2, "the locked text stays a TEXT");
|
||||
|
||||
let n = s.doc().unwrap().model.len();
|
||||
for p in [json!({"handles": []}), json!({"handles": [hs[1]]}), json!({"handles": hs, "sort": "sideways"})] {
|
||||
assert!(s.execute("txt2mtxt", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), n);
|
||||
}
|
||||
|
||||
/// The vertical extent of each group of strokes that overlap vertically: one per line of
|
||||
/// capitals, top line first.
|
||||
fn line_extents(strokes: &[Vec<cadcraft_geom::Vec2>]) -> Vec<f64> {
|
||||
let mut spans: Vec<(f64, f64)> = strokes
|
||||
.iter()
|
||||
.filter(|st| !st.is_empty())
|
||||
.map(|st| st.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))))
|
||||
.collect();
|
||||
spans.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let mut lines: Vec<(f64, f64)> = Vec::new();
|
||||
for (lo, hi) in spans {
|
||||
match lines.last_mut() {
|
||||
Some(l) if hi >= l.0 => l.0 = l.0.min(lo),
|
||||
_ => lines.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
lines.iter().map(|(lo, hi)| hi - lo).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_lines_keep_their_own_heights() {
|
||||
let mut s = Session::new();
|
||||
for (y, t, h) in [(0, "LOW", 3.5), (20, "TOP", 5.0), (10, "MID", 3.5)] {
|
||||
s.execute("text", &json!({"at": [0, y], "text": t, "height": h})).unwrap();
|
||||
}
|
||||
s.execute("txt2mtxt", &json!({"handles": s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect::<Vec<_>>()})).unwrap();
|
||||
let ms = mtexts(&s);
|
||||
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
assert_eq!((m.height, m.contents.as_str()), (5.0, "TOP\\P{\\H3.5;MID}\\P{\\H3.5;LOW}"));
|
||||
// Drawn, each line is as tall as the TEXT it came from.
|
||||
let drawn = |c: &str, h: f64| cadcraft_fonts::layout_mtext(c, m.insert.xy(), h, 0.0, 1, 0.0, 1.0).strokes;
|
||||
let got = line_extents(&drawn(&m.contents, m.height));
|
||||
let want: Vec<f64> = [("TOP", 5.0), ("MID", 3.5), ("LOW", 3.5)].iter().flat_map(|(t, h)| line_extents(&drawn(t, *h))).collect();
|
||||
assert_eq!(got.len(), 3, "{got:?}");
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g - w).abs() < 1e-6, "line heights {got:?}, TEXT heights {want:?}");
|
||||
}
|
||||
assert!(want[1] < want[0] * 0.8, "the fixture's heights differ: {want:?}");
|
||||
s.undo().unwrap();
|
||||
|
||||
// One MTEXT per TEXT: each its own height.
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
|
||||
let mut got: Vec<(String, f64)> = mtexts(&s).into_iter().map(|(_, m)| (m.contents, m.height)).collect();
|
||||
got.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(got, [("LOW".to_string(), 3.5), ("MID".to_string(), 3.5), ("TOP".to_string(), 5.0)]);
|
||||
}
|
||||
@@ -26,7 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom).params("{mode: distance|radius|angle|area, ...}").noundo(),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area, ...}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<MeasureM>::default())),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
|
||||
]
|
||||
@@ -319,6 +322,61 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick.
|
||||
#[derive(Default)]
|
||||
struct MeasureM {
|
||||
inner: Option<Box<dyn Interactive>>,
|
||||
/// Waiting for the arc or circle of the Radius option.
|
||||
radius: bool,
|
||||
}
|
||||
|
||||
impl Interactive for MeasureM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match &self.inner {
|
||||
Some(m) => m.prompt(s),
|
||||
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
|
||||
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
|
||||
.kw(&["Distance", "Radius", "ARea"])
|
||||
.default("Distance"),
|
||||
}
|
||||
}
|
||||
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.radius {
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Err(_) => s.echo("Select an arc or circle."),
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Radius" => self.radius = true,
|
||||
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
|
||||
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -68,6 +68,7 @@ struct Parser<'a> {
|
||||
i: usize,
|
||||
vars: &'a Map<String, Value>,
|
||||
depth: usize,
|
||||
unary_depth: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
@@ -121,19 +122,31 @@ impl Parser<'_> {
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
/// A unary operand reached by a leading `-`/`+` or after `^`. These recurse without passing
|
||||
/// through `expr`, so they are bounded by their own counter (`unary_depth`), leaving the
|
||||
/// parenthesis/bracket limit in `depth` untouched.
|
||||
fn operand(&mut self) -> Result<Val, String> {
|
||||
if self.unary_depth >= MAX_DEPTH {
|
||||
return Err("expression too deeply nested".into());
|
||||
}
|
||||
self.unary_depth += 1;
|
||||
let v = self.unary();
|
||||
self.unary_depth -= 1;
|
||||
v
|
||||
}
|
||||
fn unary(&mut self) -> Result<Val, String> {
|
||||
if self.eat(b'-') {
|
||||
return Ok(match self.unary()? {
|
||||
return Ok(match self.operand()? {
|
||||
Val::Num(v) => Val::Num(-v),
|
||||
Val::Pt(p) => Val::Pt(Vec2::ZERO - p),
|
||||
});
|
||||
}
|
||||
if self.eat(b'+') {
|
||||
return self.unary();
|
||||
return self.operand();
|
||||
}
|
||||
let b = self.atom()?;
|
||||
if self.eat(b'^') {
|
||||
let e = self.unary()?.num("^")?;
|
||||
let e = self.operand()?.num("^")?;
|
||||
return Ok(Val::Num(b.num("^")?.powf(e)));
|
||||
}
|
||||
Ok(b)
|
||||
@@ -395,7 +408,7 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
|
||||
if expr.len() > MAX_LEN {
|
||||
return Err("expression too long".into());
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0 };
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.peek().is_some() {
|
||||
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
|
||||
@@ -588,6 +601,25 @@ mod tests {
|
||||
assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_unary_chain_does_not_overflow_the_stack() {
|
||||
assert!(ev(&format!("{}1", "-".repeat(600))).is_err());
|
||||
assert!(ev(&format!("{}1", "+".repeat(600))).is_err());
|
||||
assert!(ev(&format!("2{}", "^2".repeat(600))).is_err());
|
||||
// The size from the report: under MAX_LEN, but ~10,000 frames deep without a guard.
|
||||
assert!(ev(&format!("{}1", "-".repeat(9990))).is_err());
|
||||
assert!(ev(&format!("2{}", "^2".repeat(4990))).is_err());
|
||||
assert_eq!(ev("--1"), Ok(Val::Num(1.0)));
|
||||
assert_eq!(ev("2^3^2"), Ok(Val::Num(512.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parenthesis_nesting_just_under_the_limit_still_parses() {
|
||||
let n = MAX_DEPTH - 1;
|
||||
assert_eq!(ev(&format!("{}1{}", "(".repeat(n), ")".repeat(n))), Ok(Val::Num(1.0)));
|
||||
assert!(ev(&format!("{}1{}", "(".repeat(n + 1), ")".repeat(n + 1))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_text() {
|
||||
assert_eq!(num_text(0.1 + 0.2), "0.3");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Paths stored in a drawing: xref paths and table data links.
|
||||
//!
|
||||
//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from
|
||||
//! the drawing's folder, and only files on this computer, or on the network share the drawing
|
||||
//! itself is on, are opened (see `craft_docpath`). A refused path is handled like a missing file,
|
||||
//! with the reason in its status. Paths the user gives a command (XATTACH, -XREF Path, DATALINK)
|
||||
//! are their own choice and are used as given.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
|
||||
/// The file to open for `stored`, a path or `file:` URL stored in the drawing saved at
|
||||
/// `document` (`None` for a drawing without a file).
|
||||
///
|
||||
/// A result that isn't absolute is refused as [`Refused::Invalid`]: without a document a
|
||||
/// relative path would depend on the working directory, and a Windows drive path names no file
|
||||
/// on other platforms.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
|
||||
let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
|
||||
let path = craft_docpath::resolve(stored.trim(), document.as_deref())?;
|
||||
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_beside_the_drawing_and_other_computers_are_refused() {
|
||||
let doc = std::env::temp_dir().join("plans").join("host.dxf");
|
||||
assert_eq!(resolve("parts/a.dxf", Some(&doc)), Ok(std::env::temp_dir().join("plans").join("parts").join("a.dxf")));
|
||||
assert_eq!(resolve("a.dxf", None), Err(Refused::Invalid));
|
||||
assert_eq!(resolve(r"\\192.0.2.1\share\a.dxf", Some(&doc)), Err(Refused::Network));
|
||||
assert_eq!(resolve("file://192.0.2.1/s/a.dxf", Some(&doc)), Err(Refused::Network));
|
||||
assert_eq!(resolve("//192.0.2.1/s/a.dxf", None), Err(Refused::Network));
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,15 @@ impl Interactive for Guarded {
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
fn preview_objects(&self, s: &Session, cursor: Vec2) -> Vec<cadcraft_doc::Entity> {
|
||||
if self.broken.get().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.preview_objects(s, cursor))).unwrap_or_else(|_| {
|
||||
self.broken.set(Some("panic in preview"));
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.broken.get().is_some() {
|
||||
return None;
|
||||
|
||||
+121
-21
@@ -13,6 +13,7 @@ pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod blockedit;
|
||||
pub mod cmd;
|
||||
pub mod doc_path;
|
||||
mod finite;
|
||||
pub mod grip_dims;
|
||||
pub mod grips;
|
||||
@@ -378,7 +379,11 @@ pub struct Settings {
|
||||
/// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or
|
||||
/// the Plot dialog's "Plot transparency", 2 always plots it.
|
||||
pub plot_transparency_override: u8,
|
||||
/// Selection cycling on (the status-bar toggle, the sign of SELECTIONCYCLING).
|
||||
pub selection_cycling: bool,
|
||||
/// What selection cycling shows over overlapping objects (SELECTIONCYCLING's magnitude): 0
|
||||
/// nothing, 1 a badge, 2 (default) the badge and a list to pick from on a click.
|
||||
pub selection_cycling_display: u8,
|
||||
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
|
||||
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
|
||||
/// 128 name).
|
||||
@@ -392,6 +397,9 @@ pub struct Settings {
|
||||
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
|
||||
pub acadlspasdoc: bool,
|
||||
pub pickadd: bool,
|
||||
/// PICKDRAG: false = two-click windows (0), true = clicks or dragging (2).
|
||||
/// Drag-only mode (1) is not supported yet.
|
||||
pub pickdrag: bool,
|
||||
/// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches
|
||||
/// with their boundaries (stored, not used yet).
|
||||
pub pickstyle: u8,
|
||||
@@ -443,6 +451,8 @@ pub struct Settings {
|
||||
pub polarsnap: bool,
|
||||
/// POLARDIST: the PolarSnap step; 0 uses the snap X spacing.
|
||||
pub polardist: f64,
|
||||
/// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0).
|
||||
pub auditctl: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -470,6 +480,7 @@ impl Default for Settings {
|
||||
transparency_display: true,
|
||||
plot_transparency_override: 1,
|
||||
selection_cycling: false,
|
||||
selection_cycling_display: 2,
|
||||
selectsimilarmode: 130,
|
||||
otrack: true,
|
||||
pickbox: 3.0,
|
||||
@@ -477,6 +488,7 @@ impl Default for Settings {
|
||||
pickfirst: true,
|
||||
acadlspasdoc: false,
|
||||
pickadd: true,
|
||||
pickdrag: true,
|
||||
pickstyle: 1,
|
||||
gripsize: 5.0,
|
||||
cursorsize: 5.0,
|
||||
@@ -501,6 +513,7 @@ impl Default for Settings {
|
||||
polaraddang: Vec::new(),
|
||||
polarsnap: false,
|
||||
polardist: 0.0,
|
||||
auditctl: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,12 +548,16 @@ pub struct LastUsed {
|
||||
pub rotate_angle: f64,
|
||||
/// SCALE factor.
|
||||
pub scale_factor: f64,
|
||||
/// TXT2MTXT's Settings.
|
||||
pub txt2mtxt: cmd::Txt2MtxtOptions,
|
||||
/// MATCHPROP Settings: the properties it copies.
|
||||
pub matchprop: cmd::MatchProps,
|
||||
/// MLEADER placement order and Options.
|
||||
pub mleader: MLeaderOptions,
|
||||
/// MLEADER's block content name (Options > Content type > Block).
|
||||
pub mleader_block: String,
|
||||
/// BREAKLINE's Block, Size and Extension.
|
||||
pub breakline: cmd::BreaklineOptions,
|
||||
}
|
||||
|
||||
impl Default for LastUsed {
|
||||
@@ -550,9 +567,11 @@ impl Default for LastUsed {
|
||||
polygon_circumscribed: false,
|
||||
rotate_angle: 0.0,
|
||||
scale_factor: 1.0,
|
||||
txt2mtxt: cmd::Txt2MtxtOptions::default(),
|
||||
matchprop: cmd::MatchProps::default(),
|
||||
mleader: MLeaderOptions::default(),
|
||||
mleader_block: String::new(),
|
||||
breakline: cmd::BreaklineOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,6 +659,10 @@ pub struct Session {
|
||||
/// 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>,
|
||||
/// Whether Ctrl is held right now (from the UI, overwritten every frame). Commands that
|
||||
/// change behavior on Ctrl — currently only ARC's "hold Ctrl to switch direction" — read
|
||||
/// this instead of receiving modifiers through `Input`, which stays free of UI state.
|
||||
pub ctrl_down: bool,
|
||||
/// Origin of the point currently being fed by `input_snapped`; never reused by typed points.
|
||||
picked_viewport: Option<snap::ViewportPoint>,
|
||||
/// The last drawing comparison (COMPARE), drawn over the drawing it was made for.
|
||||
@@ -702,6 +725,7 @@ impl Session {
|
||||
point_mods: Vec::new(),
|
||||
cursor: Vec2::ZERO,
|
||||
cursor_deferred: None,
|
||||
ctrl_down: false,
|
||||
picked_viewport: None,
|
||||
compare: None,
|
||||
tracking: snap::tracking::Tracker::default(),
|
||||
@@ -1083,6 +1107,11 @@ impl Session {
|
||||
if self.select_mode.is_some() {
|
||||
return self.select_mode_input(input);
|
||||
}
|
||||
// `_L`, `_ALL`, `_W`: the English name of a selection keyword, as scripts write it.
|
||||
let input = match input {
|
||||
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
|
||||
other => other,
|
||||
};
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& let Some(m) = select::SelectMode::from_keyword(t)
|
||||
{
|
||||
@@ -1396,15 +1425,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rubber-band preview entities for the cursor.
|
||||
/// Preview entities for the cursor: objects drawn with their own properties
|
||||
/// ([`Interactive::preview_objects`]), then the rubber band with the current properties, in
|
||||
/// the grip blue over such objects (so it shows over their fills, whatever their colour).
|
||||
pub fn preview(&self, cursor: Vec2) -> Vec<Entity> {
|
||||
let common = self.current_common();
|
||||
let kinds = match (self.lisp_preview(cursor), &self.running) {
|
||||
(Some(k), _) => k,
|
||||
(None, Some(r)) => r.machine.preview(self, cursor),
|
||||
(None, None) => Vec::new(),
|
||||
let mut common = self.current_common();
|
||||
let (kinds, mut out) = match (self.lisp_preview(cursor), &self.running) {
|
||||
(Some(k), _) => (k, Vec::new()),
|
||||
(None, Some(r)) => (r.machine.preview(self, cursor), r.machine.preview_objects(self, cursor)),
|
||||
(None, None) => (Vec::new(), Vec::new()),
|
||||
};
|
||||
kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
|
||||
if !out.is_empty() {
|
||||
common.color = cadcraft_doc::color::Color::True(cadcraft_doc::color::Rgb(0x2f, 0x6b, 0xff));
|
||||
}
|
||||
out.extend(kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }));
|
||||
out
|
||||
}
|
||||
|
||||
/// The active command's preview as a transform of existing objects (see
|
||||
@@ -1526,7 +1561,8 @@ impl Session {
|
||||
}
|
||||
// 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");
|
||||
let selection_mode = prompt.accept.select
|
||||
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
// A leading underscore asks for a keyword by its English name, whatever the interface
|
||||
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
|
||||
// overrides such as `_cen` were taken above.)
|
||||
@@ -1625,7 +1661,7 @@ impl Session {
|
||||
/// 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)) {
|
||||
if !self.settings.pickdrag || !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 });
|
||||
@@ -1654,23 +1690,87 @@ impl Session {
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => {
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
} else if !had {
|
||||
sel.extend(hs);
|
||||
}
|
||||
self.set_selection(sel);
|
||||
}
|
||||
Some(h) => self.idle_pick(h, shift),
|
||||
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An idle click that picked `h`, with [`Self::idle_click`]'s PICKADD and Shift rules.
|
||||
fn idle_pick(&mut self, h: Handle, shift: bool) {
|
||||
let pickadd = self.settings.pickadd;
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
} else if !had {
|
||||
sel.extend(hs);
|
||||
}
|
||||
self.set_selection(sel);
|
||||
}
|
||||
|
||||
/// Whether a click at `p` selects objects by picking: no command running, or a command at
|
||||
/// "Select objects" with no selection window or typed mode under way.
|
||||
pub fn click_picks(&self) -> bool {
|
||||
self.pending_window.is_none()
|
||||
&& match self.running.as_ref() {
|
||||
None => self.lisp_prompt().is_none(),
|
||||
Some(r) => self.select_mode.is_none() && r.machine.prompt(self).accept.select,
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects under the pick box at `p` when selection cycling is on and more than one is
|
||||
/// there: what a click at `p` would choose between.
|
||||
fn cycling_hits(&self, p: Vec2) -> Vec<Handle> {
|
||||
if !self.settings.selection_cycling {
|
||||
return Vec::new();
|
||||
}
|
||||
let hs = self.pick_candidates(p);
|
||||
if hs.len() > 1 { hs } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// Every object under the pick box at `p`, nearest first, when a click there picks objects
|
||||
/// (empty otherwise): what Shift+Space steps the highlight through.
|
||||
pub fn pick_candidates(&self, p: Vec2) -> Vec<Handle> {
|
||||
if !self.click_picks() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
|
||||
/// objects a click there would choose between.
|
||||
pub fn cycling_badge(&self, p: Vec2) -> bool {
|
||||
self.settings.selection_cycling_display >= 1 && !self.cycling_hits(p).is_empty()
|
||||
}
|
||||
|
||||
/// The objects a click at `p` lists to choose from (SELECTIONCYCLING 2), nearest first; empty
|
||||
/// when the click should pick as usual.
|
||||
pub fn cycling_candidates(&self, p: Vec2) -> Vec<Handle> {
|
||||
if self.settings.selection_cycling_display < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
self.cycling_hits(p)
|
||||
}
|
||||
|
||||
/// Pick `h` from the selection cycling list, as a click on it alone would have: into the
|
||||
/// command at "Select objects", else into the selection (Shift as for [`Self::idle_click`]).
|
||||
pub fn pick_cycled(&mut self, h: Handle, shift: bool) -> Result<()> {
|
||||
// The list may be older than an undo or an erase.
|
||||
if self.doc()?.entity(h).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.running.is_some() {
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
return self.input(Input::Pick(hs));
|
||||
}
|
||||
self.idle_pick(h, shift);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember the selection used by a command as "Previous".
|
||||
pub fn remember_selection(&mut self, sel: &[Handle]) {
|
||||
if let Ok(st) = self.state_mut() {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `angtof`).
|
||||
//!
|
||||
//! Entity lists use the DXF groups the file reader and writer use (through
|
||||
//! [`crate::cmd::file::entity_codec`]), so what `entget` shows is what a DXF file holds.
|
||||
//! [`crate::cmd::file::entity_codec`]), so what `entget` shows is what a DXF file holds, except
|
||||
//! for angles, which are radians in a list and degrees in a file.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -185,6 +186,27 @@ fn point_group(code: i32) -> bool {
|
||||
(10..=18).contains(&code) || (110..=112).contains(&code) || code == 210 || (1010..=1013).contains(&code)
|
||||
}
|
||||
|
||||
/// Groups 50–58 are angles: degrees in a DXF file, radians in entity lists (DXF Reference,
|
||||
/// group code value types). `f` converts each one; an MTEXT's 50 after its column type (75) is
|
||||
/// a column height.
|
||||
fn convert_angles(kind: &str, tags: &mut [Tag], f: fn(f64) -> f64) {
|
||||
let mtext = kind.eq_ignore_ascii_case("MTEXT");
|
||||
for t in tags {
|
||||
if mtext && t.code == 75 {
|
||||
break;
|
||||
}
|
||||
if !(50..=58).contains(&t.code) {
|
||||
continue;
|
||||
}
|
||||
let v = match t.value {
|
||||
cadcraft_dxf::Value::Real(r) => r,
|
||||
cadcraft_dxf::Value::Int(i) => i as f64,
|
||||
_ => continue,
|
||||
};
|
||||
t.value = cadcraft_dxf::Value::Real(f(v));
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_value(t: &Tag) -> Value {
|
||||
match &t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
|
||||
@@ -274,7 +296,8 @@ pub fn entget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let h = ename(&a[0])?;
|
||||
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let Some(e) = d.entity(h) else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let (kind, tags) = (codec()?.groups)(d, e);
|
||||
let (kind, mut tags) = (codec()?.groups)(d, e);
|
||||
convert_angles(&kind, &mut tags, f64::to_radians);
|
||||
let layout = match space_of(d, h) {
|
||||
Some(Space::Paper(name)) => name,
|
||||
_ => "Model".into(),
|
||||
@@ -296,6 +319,7 @@ pub fn entget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
/// The entity a list describes, as the reader makes it; a missing layer (8) is the current one.
|
||||
fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc::Common, cadcraft_doc::EntityKind), LispError> {
|
||||
let (kind, mut tags) = list_to_groups(list)?;
|
||||
convert_angles(&kind, &mut tags, f64::to_degrees);
|
||||
if !tags.iter().any(|t| t.code == 8) {
|
||||
let layer = crate::sysvars::get(ctx.s, "CLAYER").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "0".into());
|
||||
tags.insert(0, Tag::s(8, layer));
|
||||
@@ -616,7 +640,8 @@ fn parse_filter_item(items: &[Value], i: &mut usize, depth: usize) -> Result<Fil
|
||||
fn matches_filter(d: &cadcraft_doc::Drawing, e: &Entity, f: &Filter) -> bool {
|
||||
let groups = || match crate::cmd::file::entity_codec() {
|
||||
Some(c) => {
|
||||
let (kind, tags) = (c.groups)(d, e);
|
||||
let (kind, mut tags) = (c.groups)(d, e);
|
||||
convert_angles(&kind, &mut tags, f64::to_radians);
|
||||
let mut l = vec![Value::cons(Value::Int(0), Value::str(&kind))];
|
||||
l.extend(groups_to_list(&tags));
|
||||
l
|
||||
|
||||
@@ -30,6 +30,19 @@ fn ok(s: &mut Session, code: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_print_subnormal_and_rounding() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "1e-310"), "1.0e-310");
|
||||
assert_eq!(ok(&mut s, "5e-324"), "4.94066e-324");
|
||||
assert_eq!(ok(&mut s, "9.9999996e10"), "1.0e+011");
|
||||
assert_eq!(ok(&mut s, "9.9999996e-7"), "1.0e-006");
|
||||
assert_eq!(super::value::real_text(-1e-310), "-1.0e-310");
|
||||
assert_eq!(super::value::real_text(1e308), "1.0e+308");
|
||||
assert_eq!(super::value::real_text(999999.5), "1.0e+006");
|
||||
assert_eq!(super::value::real_text(0.000009999996), "0.00001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_follows_autolisp() {
|
||||
let mut s = session();
|
||||
@@ -244,6 +257,61 @@ fn entity_lists_round_trip() {
|
||||
assert_eq!(ok(&mut s, "(handent \"ZZZZZZ\")"), "nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_lists_hold_angles_in_radians() {
|
||||
let mut s = session();
|
||||
let near = |s: &mut Session, expr: &str, want: &str| {
|
||||
let code = format!("(equal {expr} {want} 1e-9)");
|
||||
assert_eq!(ok(s, &code), "T", "{expr} is {}, not {want}", ok(s, expr));
|
||||
};
|
||||
// A DXF file holds an ARC's 50 and 51 in degrees; entget gives radians (#1165).
|
||||
s.script("ARC\nC\n0,0\n10,0\n0,10\n").unwrap();
|
||||
near(&mut s, "(cdr (assoc 50 (entget (entlast))))", "0.0");
|
||||
near(&mut s, "(cdr (assoc 51 (entget (entlast))))", "(/ pi 2)");
|
||||
// ssget filters compare against the values entget shows.
|
||||
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((0 . \"ARC\") (-4 . \"<\") (51 . 2.0))))"), "1");
|
||||
// entmod takes radians.
|
||||
ok(&mut s, "(setq d (entget (entlast))) (entmod (subst (cons 51 pi) (assoc 51 d) d))");
|
||||
let end = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::Arc(a) => Some(a.end),
|
||||
_ => None,
|
||||
});
|
||||
assert!(end.is_some_and(|a| (a - std::f64::consts::PI).abs() < 1e-9), "{end:?}");
|
||||
|
||||
// TEXT rotation (50) and oblique angle (51), through entmake.
|
||||
ok(&mut s, "(entmake (list '(0 . \"TEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(1 . \"abc\") (cons 50 (/ pi 6)) '(51 . 0.25)))");
|
||||
let text = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::Text(t) => Some((t.rotation, t.oblique)),
|
||||
_ => None,
|
||||
});
|
||||
assert!(text.is_some_and(|(r, o)| (r - std::f64::consts::FRAC_PI_6).abs() < 1e-9 && (o - 0.25).abs() < 1e-9), "{text:?}");
|
||||
near(&mut s, "(cdr (assoc 50 (entget (entlast))))", "(/ pi 6)");
|
||||
near(&mut s, "(cdr (assoc 51 (entget (entlast))))", "0.25");
|
||||
|
||||
// HATCH pattern angle (52) and pattern line angle (53): ANSI31 at 0 has its lines at 45°.
|
||||
s.script("CIRCLE\n0,0\n10\n-HATCH\nP\nANSI31\n1\n0\n0,0\n\n").unwrap();
|
||||
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget (entlast))))"), "\"HATCH\"");
|
||||
near(&mut s, "(cdr (assoc 52 (entget (entlast))))", "0.0");
|
||||
near(&mut s, "(cdr (assoc 53 (entget (entlast))))", "(/ pi 4)");
|
||||
|
||||
// MTEXT: 50 before the column type (75) is the rotation, after it a column height.
|
||||
ok(
|
||||
&mut s,
|
||||
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 30.0) '(50 . 20.0)))",
|
||||
);
|
||||
let mtext = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::MText(t) => Some((t.rotation, t.columns.as_ref().map(|c| c.heights.clone()))),
|
||||
_ => None,
|
||||
});
|
||||
assert!(
|
||||
mtext.as_ref().is_some_and(|(r, h)| (r - std::f64::consts::FRAC_PI_3).abs() < 1e-9 && h.as_deref() == Some(&[30.0, 20.0][..])),
|
||||
"{mtext:?}"
|
||||
);
|
||||
near(&mut s, "(cdr (assoc 50 (entget (entlast))))", "(/ pi 3)");
|
||||
let d = ok(&mut s, "(entget (entlast))");
|
||||
assert!(d.contains("(50 . 30.0) (50 . 20.0)"), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_sets_and_filters() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -398,16 +398,20 @@ pub fn real_text(r: f64) -> String {
|
||||
if r == 0.0 {
|
||||
return "0.0".into();
|
||||
}
|
||||
let exp = r.abs().log10().floor() as i32;
|
||||
// Round to six significant digits first so the exponent already includes any carry
|
||||
// (999999.5 -> 1.00000e6) and subnormals never divide by an underflowed power of ten.
|
||||
let sci = format!("{:.5e}", r.abs());
|
||||
let (mantissa, exp) = sci.split_once('e').unwrap_or((sci.as_str(), "0"));
|
||||
let exp: i32 = exp.parse().unwrap_or(0);
|
||||
// As C's %g with six digits: exponent form outside 1e-5 ..< 1e6.
|
||||
if !(-5..6).contains(&exp) {
|
||||
// Exponent form: d.ddddde+NNN
|
||||
let m = r / 10f64.powi(exp);
|
||||
let mut s = trim_fraction(&format!("{m:.5}"));
|
||||
let mut s = trim_fraction(mantissa);
|
||||
if !s.contains('.') {
|
||||
s.push_str(".0");
|
||||
}
|
||||
return format!("{s}e{}{:03}", if exp < 0 { '-' } else { '+' }, exp.abs());
|
||||
let sign = if r < 0.0 { "-" } else { "" };
|
||||
return format!("{sign}{s}e{}{:03}", if exp < 0 { '-' } else { '+' }, exp.abs());
|
||||
}
|
||||
let decimals = (5 - exp).max(0) as usize;
|
||||
let mut s = trim_fraction(&format!("{r:.decimals$}"));
|
||||
|
||||
@@ -41,8 +41,8 @@ pub struct Prompt {
|
||||
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
|
||||
/// pick arrives as the plain point it was picked at.
|
||||
pub deferred: bool,
|
||||
/// The base is a window's first corner (ZOOM Window): ortho does not bend the opposite
|
||||
/// corner, so the window stays a rectangle.
|
||||
/// The base is a rectangle's first corner (ZOOM Window, RECTANG, ARRAYRECT's unit cell): ortho
|
||||
/// does not bend the opposite corner, so the rectangle keeps its area.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub corner: bool,
|
||||
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
|
||||
@@ -57,6 +57,11 @@ pub struct Prompt {
|
||||
/// prompt (the routine writes all of it).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub raw: bool,
|
||||
/// The prompt asks for a distance from `base` (a radius, a diameter, a length), and a point
|
||||
/// answers with its distance from it: Dynamic Input shows one distance field instead of the
|
||||
/// pointer boxes.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub distance: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
@@ -109,6 +114,11 @@ impl Prompt {
|
||||
self.corner = true;
|
||||
self
|
||||
}
|
||||
/// Asks for a distance from the base (the `distance` field).
|
||||
pub fn distance(mut self) -> Self {
|
||||
self.distance = true;
|
||||
self
|
||||
}
|
||||
/// Accept deferred tangent/perpendicular snaps (the `deferred` field).
|
||||
pub fn deferred(mut self) -> Self {
|
||||
self.deferred = true;
|
||||
@@ -209,6 +219,12 @@ pub trait Interactive: Send {
|
||||
fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Preview objects drawn with their own properties (colour, layer, linetype) rather than
|
||||
/// the current layer's, as [`Interactive::preview`]'s are: a block reference re-evaluated
|
||||
/// under the cursor, drawn as it will look. Drawn under the rubber band.
|
||||
fn preview_objects(&self, _s: &Session, _cursor: Vec2) -> Vec<cadcraft_doc::Entity> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The preview as existing objects under one transform, when it is that (MOVE, COPY,
|
||||
/// ROTATE, SCALE). The UI then draws them from geometry uploaded once instead of rebuilding
|
||||
/// the transformed copies every frame; `None` falls back to [`Interactive::preview`].
|
||||
|
||||
@@ -209,6 +209,21 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
|
||||
best.map(|(_, h)| h)
|
||||
}
|
||||
|
||||
/// Every entity within `aperture` of `p`, nearest first and topmost among equals (so the first is
|
||||
/// what [`pick`] chooses): the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
let mut hits: Vec<(f64, Handle)> = cands
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle))
|
||||
.filter(|(dist, _)| *dist <= aperture)
|
||||
.collect();
|
||||
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
hits.into_iter().map(|(_, h)| h).collect()
|
||||
}
|
||||
|
||||
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
|
||||
/// locked layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 objects in groups.
|
||||
pub fn preview_filtered(d: &Drawing, e: &Entity, filter: u8) -> bool {
|
||||
|
||||
@@ -770,7 +770,8 @@ pub struct CursorQuery {
|
||||
pub tol: f64,
|
||||
/// Seconds on any steady clock (acquisition timing).
|
||||
pub now: f64,
|
||||
/// Shift is held.
|
||||
/// Shift is held: it overrides ortho for as long as it is held, and acquires tracking
|
||||
/// points with POLARMODE bit 8.
|
||||
pub shift: bool,
|
||||
}
|
||||
|
||||
@@ -780,7 +781,10 @@ impl crate::Session {
|
||||
/// tracking, Extension, Parallel, Apparent Intersection), then ortho.
|
||||
pub fn snap_cursor(&mut self, raw: Vec2, q: &CursorQuery) -> CursorSnap {
|
||||
// Snap and grid spacing are the drawing's (SNAPUNIT/GRIDUNIT live in its header).
|
||||
let s = self.drafting_settings();
|
||||
let mut s = self.drafting_settings();
|
||||
// Holding Shift overrides ortho while it is held: on when ORTHOMODE is off, off when it
|
||||
// is on (ORTHOMODE itself is unchanged). Ortho on also turns polar tracking off.
|
||||
s.orthomode ^= q.shift;
|
||||
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());
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{EngineError, Result, Session};
|
||||
mod table;
|
||||
|
||||
const SESSION_VARS: &[&str] = &[
|
||||
"AUDITCTL",
|
||||
"OSMODE",
|
||||
"OSNAPHATCH",
|
||||
"ANNOAUTOSCALE",
|
||||
@@ -35,6 +36,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"DYNPROMPT",
|
||||
"LWDISPLAY",
|
||||
"PLOTTRANSPARENCYOVERRIDE",
|
||||
"SELECTIONCYCLING",
|
||||
"SELECTSIMILARMODE",
|
||||
"OBJECTISOLATIONMODE",
|
||||
"QPMODE",
|
||||
@@ -54,6 +56,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"PICKFIRST",
|
||||
"ACADLSPASDOC",
|
||||
"PICKADD",
|
||||
"PICKDRAG",
|
||||
"PICKSTYLE",
|
||||
"GRIPSIZE",
|
||||
"CURSORSIZE",
|
||||
@@ -102,6 +105,11 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"DYNPROMPT" => json!(i32::from(st.dynprompt)),
|
||||
"LWDISPLAY" => json!(i32::from(st.lwdisplay)),
|
||||
"PLOTTRANSPARENCYOVERRIDE" => json!(st.plot_transparency_override),
|
||||
// Negative while turned off, so the toggle keeps the display it turns back on.
|
||||
"SELECTIONCYCLING" => {
|
||||
let d = i32::from(st.selection_cycling_display);
|
||||
json!(if st.selection_cycling { d } else { -d })
|
||||
}
|
||||
"SELECTSIMILARMODE" => json!(st.selectsimilarmode),
|
||||
// Isolation is temporary: hidden objects show again when the drawing is reopened.
|
||||
"OBJECTISOLATIONMODE" => json!(0),
|
||||
@@ -122,13 +130,14 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"PICKFIRST" => json!(i32::from(st.pickfirst)),
|
||||
"ACADLSPASDOC" => json!(i32::from(st.acadlspasdoc)),
|
||||
"PICKADD" => json!(i32::from(st.pickadd)),
|
||||
"PICKDRAG" => json!(i32::from(st.pickdrag) * 2),
|
||||
"PICKSTYLE" => json!(st.pickstyle),
|
||||
"GRIPSIZE" => json!(st.gripsize),
|
||||
"CURSORSIZE" => json!(st.cursorsize),
|
||||
"MAXARRAY" => json!(st.maxarray),
|
||||
"ZOOMFACTOR" => json!(st.zoomfactor),
|
||||
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
|
||||
// Held in radians (as `$ANGBASE` in DXF); reported in degrees, like POLARANG.
|
||||
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
|
||||
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
|
||||
"CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)),
|
||||
// Process-wide font substitution (profile settings, not saved in the drawing).
|
||||
@@ -266,6 +275,11 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"PLOTTRANSPARENCYOVERRIDE" => {
|
||||
st.plot_transparency_override = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)?
|
||||
}
|
||||
"SELECTIONCYCLING" => {
|
||||
let i = as_i(v).filter(|i| (-2..=2).contains(i)).ok_or_else(bad)?;
|
||||
st.selection_cycling = i > 0;
|
||||
st.selection_cycling_display = u8::try_from(i.unsigned_abs()).map_err(|_| bad())?;
|
||||
}
|
||||
"SELECTSIMILARMODE" => st.selectsimilarmode = as_i(v).filter(|b| (0..=255).contains(b)).ok_or_else(bad)? as u32,
|
||||
"OBJECTISOLATIONMODE" => match as_i(v) {
|
||||
Some(0) => {}
|
||||
@@ -289,6 +303,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"PICKFIRST" => st.pickfirst = as_bool(v).ok_or_else(bad)?,
|
||||
"ACADLSPASDOC" => st.acadlspasdoc = as_bool(v).ok_or_else(bad)?,
|
||||
"PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,
|
||||
"PICKDRAG" => st.pickdrag = as_i(v).filter(|b| matches!(b, 0 | 2)).map(|b| b == 2).ok_or_else(bad)?,
|
||||
"PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).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),
|
||||
|
||||
@@ -81,7 +81,8 @@ const LINEWEIGHTS: [i64; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53,
|
||||
|
||||
/// On/off session settings: name, read, write.
|
||||
type Toggle = (&'static str, fn(&Settings) -> bool, fn(&mut Settings, bool));
|
||||
const SETTINGS: &[Toggle] = &[("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)];
|
||||
const SETTINGS: &[Toggle] =
|
||||
&[("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)];
|
||||
|
||||
fn header_var(name: &str) -> Option<&'static (&'static str, Kind, Def)> {
|
||||
HEADER.iter().find(|(n, _, _)| *n == name)
|
||||
|
||||
@@ -1274,6 +1274,25 @@ fn bare_variable_name_runs_setvar() {
|
||||
assert!(s.cmdline("-pickadd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ltscale_asks_for_the_scale() {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({"name": "LTSCALE", "value": 2.5})).unwrap();
|
||||
s.cmdline("ltscale").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter new linetype scale factor <2.5000>"), "{}", s.prompt_text());
|
||||
// A scale that is not positive is refused and asked again.
|
||||
s.cmdline("0").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
s.cmdline("0.1").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1);
|
||||
// Enter keeps the current scale.
|
||||
s.cmdline("lts").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1);
|
||||
}
|
||||
|
||||
fn setvar_prompt_open(s: &Session) -> bool {
|
||||
s.running.as_ref().is_some_and(|r| r.id == "setvar")
|
||||
}
|
||||
|
||||
+106
-6
@@ -10,10 +10,13 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
}
|
||||
let prec = luprec.clamp(0, 8) as usize;
|
||||
match lunits {
|
||||
1 => format!("{:.*E}", prec, v),
|
||||
1 => scientific(v, prec),
|
||||
3 => {
|
||||
let neg = v < 0.0;
|
||||
let a = v.abs();
|
||||
// Round at the displayed precision before splitting, so 11.999 carries to 1'-0.00".
|
||||
let scale = 10f64.powi(prec as i32);
|
||||
let rounded = (v.abs() * scale).round() / scale;
|
||||
let a = if rounded.is_finite() { rounded } else { v.abs() };
|
||||
let ft = (a / 12.0).floor();
|
||||
let inch = a - ft * 12.0;
|
||||
format!("{}{}'-{:.*}\"", if neg { "-" } else { "" }, ft as i64, prec, inch)
|
||||
@@ -54,6 +57,21 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`).
|
||||
fn scientific(v: f64, prec: usize) -> String {
|
||||
let s = format!("{:.*E}", prec, v);
|
||||
match s.split_once('E') {
|
||||
Some((mantissa, exp)) => {
|
||||
let (sign, digits) = match exp.strip_prefix('-') {
|
||||
Some(d) => ('-', d),
|
||||
None => ('+', exp),
|
||||
};
|
||||
format!("{mantissa}E{sign}{digits:0>2}")
|
||||
}
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
@@ -84,8 +102,8 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
2 => format!("{:.*}g", prec, deg / 0.9),
|
||||
3 => format!("{:.*}r", prec, cadcraft_geom::norm_angle(a)),
|
||||
2 => format!("{:.*}g", prec, wrap_full_turn(deg / 0.9, 400.0, prec)),
|
||||
3 => format!("{:.*}r", prec, wrap_full_turn(cadcraft_geom::norm_angle(a), 2.0 * PI, prec)),
|
||||
4 => {
|
||||
// N/S <angle from N/S toward E/W> E/W
|
||||
let (ns, ew, ang) = if deg <= 90.0 {
|
||||
@@ -99,10 +117,15 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
};
|
||||
format!("{ns} {:.*}d {ew}", prec, ang)
|
||||
}
|
||||
_ => format!("{:.*}", prec, deg),
|
||||
_ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the same value as a full turn.
|
||||
fn wrap_full_turn(v: f64, full: f64, prec: usize) -> f64 {
|
||||
if format!("{v:.prec$}") == format!("{full:.prec$}") { 0.0 } else { v }
|
||||
}
|
||||
|
||||
/// Parse a distance typed by the user: decimal, `1'6"`, `1'-6 1/2"`, `6 1/2`, `3/4`, `1e3`.
|
||||
pub fn parse_distance(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
@@ -118,14 +141,20 @@ pub fn parse_distance(s: &str) -> Option<f64> {
|
||||
};
|
||||
let mut total = 0.0;
|
||||
let mut rest = t;
|
||||
let mut parsed = false;
|
||||
if let Some(i) = rest.find('\'') {
|
||||
let ft: f64 = rest.get(..i)?.trim().parse().ok()?;
|
||||
total += ft * 12.0;
|
||||
parsed = true;
|
||||
rest = rest.get(i + 1..)?.trim_start_matches('-').trim();
|
||||
}
|
||||
let rest = rest.trim_end_matches('"').trim();
|
||||
if !rest.is_empty() {
|
||||
total += parse_mixed(rest)?;
|
||||
parsed = true;
|
||||
}
|
||||
if !parsed {
|
||||
return None;
|
||||
}
|
||||
let v = if neg { -total } else { total };
|
||||
v.is_finite().then_some(v)
|
||||
@@ -157,7 +186,7 @@ fn parse_mixed(s: &str) -> Option<f64> {
|
||||
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).
|
||||
/// ANGBASE: the direction of angle 0, in radians counterclockwise from +X (`$ANGBASE` in DXF is in degrees).
|
||||
pub angbase: f64,
|
||||
/// ANGDIR 1: positive angles turn clockwise.
|
||||
pub clockwise: bool,
|
||||
@@ -197,6 +226,20 @@ impl AngleSettings {
|
||||
let v = self.angbase + if self.clockwise { -a } else { a };
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// Direction `dir` (radians counterclockwise from +X) as the drawing shows directions, in
|
||||
/// radians: from ANGBASE in the ANGDIR sense, 0 up to a full turn. (A block turned 2.23°
|
||||
/// shows as 87.77 in a drawing measured clockwise from north, as in AutoCAD's Properties.)
|
||||
pub fn shown(&self, dir: f64) -> f64 {
|
||||
let a = if self.clockwise { self.angbase - dir } else { dir - self.angbase };
|
||||
a.rem_euclid(std::f64::consts::TAU)
|
||||
}
|
||||
|
||||
/// The direction (radians counterclockwise from +X) the drawing shows as `v` (radians): the
|
||||
/// inverse of [`AngleSettings::shown`].
|
||||
pub fn unshown(&self, v: f64) -> f64 {
|
||||
(self.angbase + if self.clockwise { -v } else { v }).rem_euclid(std::f64::consts::TAU)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a direction typed with the default angle settings (degrees, 0 = +X, counterclockwise):
|
||||
@@ -286,6 +329,22 @@ pub const DEG: f64 = PI / 180.0;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn directions_show_from_angbase_in_the_angdir_sense() {
|
||||
let north_clockwise = AngleSettings { aunits: 0, angbase: 90f64.to_radians(), clockwise: true };
|
||||
// AutoCAD shows a block turned 2.22729505553431° as 87.77 in such a drawing.
|
||||
let shown = north_clockwise.shown(2.22729505553431f64.to_radians()).to_degrees();
|
||||
assert!((shown - 87.77270494446569).abs() < 1e-9, "{shown}");
|
||||
let back = north_clockwise.unshown(87.77270494446569f64.to_radians()).to_degrees();
|
||||
assert!((back - 2.22729505553431).abs() < 1e-9, "{back}");
|
||||
// The defaults show directions as they are.
|
||||
let plain = AngleSettings::default();
|
||||
assert!((plain.shown(0.5) - 0.5).abs() < 1e-12 && (plain.unshown(0.5) - 0.5).abs() < 1e-12);
|
||||
// Counterclockwise from a base of 30°: 45° shows as 15.
|
||||
let based = AngleSettings { aunits: 0, angbase: 30f64.to_radians(), clockwise: false };
|
||||
assert!((based.shown(45f64.to_radians()).to_degrees() - 15.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_and_arch() {
|
||||
assert_eq!(format_distance(28.98524, 2, 4), "28.9852");
|
||||
@@ -294,6 +353,27 @@ mod tests {
|
||||
assert_eq!(format_distance(30.0, 3, 2), "2'-6.00\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engineering_rounding_carries_to_feet() {
|
||||
assert_eq!(format_distance(11.999, 3, 2), "1'-0.00\"");
|
||||
assert_eq!(format_distance(23.999, 3, 2), "2'-0.00\"");
|
||||
assert_eq!(format_distance(-11.999, 3, 2), "-1'-0.00\"");
|
||||
assert_eq!(format_distance(11.5, 3, 2), "0'-11.50\"");
|
||||
assert_eq!(format_distance(12.0, 3, 2), "1'-0.00\"");
|
||||
assert_eq!(format_distance(11.6, 3, 0), "1'-0\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scientific_exponent_is_signed_and_padded() {
|
||||
assert_eq!(format_distance(17.5, 1, 4), "1.7500E+01");
|
||||
assert_eq!(format_distance(0.00001, 1, 2), "1.00E-05");
|
||||
assert_eq!(format_distance(1234.5, 1, 3), "1.234E+03");
|
||||
assert_eq!(format_distance(12345678.0, 1, 3), "1.235E+07");
|
||||
assert_eq!(format_distance(-17.5, 1, 4), "-1.7500E+01");
|
||||
assert_eq!(format_distance(0.0, 1, 2), "0.00E+00");
|
||||
assert_eq!(format_distance(1e100, 1, 1), "1.0E+100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dms_display_precision() {
|
||||
let angle = (12.0 + 34.0 / 60.0 + 56.78912 / 3600.0_f64).to_radians();
|
||||
@@ -324,6 +404,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_to_a_full_turn_wraps_to_zero() {
|
||||
let almost_turn = 359.9999_f64.to_radians();
|
||||
assert_eq!(format_angle(almost_turn, 0, 2), "0.00");
|
||||
assert_eq!(format_angle(almost_turn, 2, 2), "0.00g");
|
||||
assert_eq!(format_angle(2.0 * PI - 1e-9, 3, 2), "0.00r");
|
||||
assert_eq!(format_angle(359.99_f64.to_radians(), 0, 1), "0.0");
|
||||
assert_eq!(format_angle(359.9_f64.to_radians(), 0, 1), "359.9");
|
||||
assert_eq!(format_angle(1.0, 3, 2), "1.00r");
|
||||
assert_eq!(format_angle(6.25, 3, 1), "6.2r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_distances() {
|
||||
assert_eq!(parse_distance("12.5"), Some(12.5));
|
||||
@@ -335,6 +427,14 @@ mod tests {
|
||||
assert_eq!(parse_distance(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_distance_needs_a_number() {
|
||||
assert_eq!(parse_distance("-"), None);
|
||||
assert_eq!(parse_distance("\""), None);
|
||||
assert_eq!(parse_distance("-\""), None);
|
||||
assert_eq!(parse_distance("1'"), Some(12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angles() {
|
||||
assert!((parse_angle("90").unwrap() - PI / 2.0).abs() < 1e-12);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! ARC's "hold Ctrl to switch direction": the UI writes `Session::ctrl_down` every frame and
|
||||
//! the command reads it. Two cases, and they flip differently on purpose:
|
||||
//!
|
||||
//! * Continue (the tangent comes from the previous curve): negating the start tangent makes
|
||||
//! `arc_sed` pick the mirror arc through the same two endpoints.
|
||||
//! * Center,Start,End (the two picks fix the chord): swapping the start/end angles walks the
|
||||
//! other way around the same circle.
|
||||
//!
|
||||
//! Both should land on the same circle with its two halves swapped, so the assertions below
|
||||
//! are the same: same center, same radius, and the start/end angles are exchanged.
|
||||
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
|
||||
/// `(center, radius, start angle, end angle)` of the last arc in the model.
|
||||
fn last_arc(s: &Session) -> (Vec2, f64, f64, f64) {
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::Arc(a) => (a.center.xy(), a.radius, a.start, a.end),
|
||||
k => panic!("expected an arc, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// CCW sweep from `start` to `end`, both normalised to `[0, TAU)`.
|
||||
fn sweep(start: f64, end: f64) -> f64 {
|
||||
(end - start).rem_euclid(std::f64::consts::TAU)
|
||||
}
|
||||
|
||||
/// Draw a line from the origin to (10,0), then `arc` + Enter continues from its end at (10,0)
|
||||
/// with the outgoing tangent +X. The end point is picked at (20,5).
|
||||
fn run_continue(ctrl: bool) -> (Vec2, f64, f64, f64) {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line").unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(10.0, 0.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.cmdline("arc").unwrap();
|
||||
// Enter at the first point prompt continues the last curve.
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.prompt_text().contains("hold Ctrl"), "Continue prompt should advertise Ctrl: {}", s.prompt_text());
|
||||
s.ctrl_down = ctrl;
|
||||
s.input(Input::Point(Vec2::new(20.0, 5.0))).unwrap();
|
||||
last_arc(&s)
|
||||
}
|
||||
|
||||
/// ARC Center,Start,End: center (0,0), start (5,0) at 0°, end (0,5) at 90°.
|
||||
fn run_cse(ctrl: bool) -> (Vec2, f64, f64, f64) {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("arc c").unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 0.0))).unwrap();
|
||||
assert!(s.prompt_text().contains("hold Ctrl"), "Cse end-point prompt should advertise Ctrl: {}", s.prompt_text());
|
||||
s.ctrl_down = ctrl;
|
||||
s.input(Input::Point(Vec2::new(0.0, 5.0))).unwrap();
|
||||
last_arc(&s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_ctrl_flips_the_arc_through_the_same_endpoints() {
|
||||
let plain = run_continue(false);
|
||||
let flipped = run_continue(true);
|
||||
assert!(plain.0.dist(flipped.0) < 1e-9, "center moved: {:?} vs {:?}", plain.0, flipped.0);
|
||||
assert!(near(plain.1, flipped.1), "radius changed: {} vs {}", plain.1, flipped.1);
|
||||
assert!(
|
||||
near(plain.2, flipped.3) && near(plain.3, flipped.2),
|
||||
"Ctrl must swap start/end angles: plain ({}, {}) vs flipped ({}, {})",
|
||||
plain.2,
|
||||
plain.3,
|
||||
flipped.2,
|
||||
flipped.3
|
||||
);
|
||||
let total = sweep(plain.2, plain.3) + sweep(flipped.2, flipped.3);
|
||||
assert!(
|
||||
near(total, std::f64::consts::TAU),
|
||||
"the two arcs must be the two halves of the circle: {} + {} != TAU",
|
||||
sweep(plain.2, plain.3),
|
||||
sweep(flipped.2, flipped.3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cse_ctrl_reverses_by_swapping_the_endpoint_angles() {
|
||||
let plain = run_cse(false);
|
||||
let flipped = run_cse(true);
|
||||
assert!(plain.0.dist(flipped.0) < 1e-9, "center moved: {:?} vs {:?}", plain.0, flipped.0);
|
||||
assert!(near(plain.1, flipped.1), "radius changed: {} vs {}", plain.1, flipped.1);
|
||||
assert!(
|
||||
near(plain.2, flipped.3) && near(plain.3, flipped.2),
|
||||
"Ctrl must swap start/end angles: plain ({}, {}) vs flipped ({}, {})",
|
||||
plain.2,
|
||||
plain.3,
|
||||
flipped.2,
|
||||
flipped.3
|
||||
);
|
||||
let total = sweep(plain.2, plain.3) + sweep(flipped.2, flipped.3);
|
||||
assert!(near(total, std::f64::consts::TAU), "sum of sweeps must be TAU");
|
||||
}
|
||||
|
||||
/// Sanity: without Ctrl the Continue arc uses the tangent as-is (center above the chord).
|
||||
#[test]
|
||||
fn no_ctrl_is_the_old_behavior() {
|
||||
let plain = run_continue(false);
|
||||
assert!(plain.0.y > 0.0, "tangent as-is puts the center above the chord: {:?}", plain.0);
|
||||
}
|
||||
@@ -300,3 +300,31 @@ fn recover_opens_the_file_with_the_fixes_applied() {
|
||||
assert_eq!(s.docs.len(), docs);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auditctl_generates_adt_report_file() {
|
||||
let mut s = Session::new();
|
||||
// Default is 0 (off)
|
||||
assert_eq!(s.execute("getvar", &json!({"name": "AUDITCTL"})).unwrap()["value"], json!(0));
|
||||
s.execute("setvar", &json!({"name": "AUDITCTL", "value": 1})).unwrap();
|
||||
assert_eq!(s.execute("getvar", &json!({"name": "AUDITCTL"})).unwrap()["value"], json!(1));
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-auditctl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dxf_path = dir.join("drawing_test.dxf");
|
||||
std::fs::write(&dxf_path, b"dummy").unwrap();
|
||||
|
||||
s.state_mut().unwrap().path = Some(dxf_path.to_string_lossy().to_string());
|
||||
s.state_mut().unwrap().title = "drawing_test.dxf".to_string();
|
||||
|
||||
let r = s.execute("audit", &json!({"fix": false})).unwrap();
|
||||
assert_eq!(r["errors"], 0);
|
||||
|
||||
let adt_path = dxf_path.with_extension("adt");
|
||||
assert!(adt_path.exists(), "AUDITCTL 1 must generate .adt log file");
|
||||
let content = std::fs::read_to_string(&adt_path).unwrap();
|
||||
assert!(content.contains("Audit of drawing:"), "{content}");
|
||||
assert!(content.contains("Audit: 0 errors found"), "{content}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! BLOCK's `keep` parameter takes convert, retain or delete, in any case. Anything else is an
|
||||
//! error that changes nothing (issue #935: a misspelt value erased the objects without making a
|
||||
//! reference to the new block).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(s: &mut Session) -> Value {
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap()["handles"][0].clone()
|
||||
}
|
||||
|
||||
fn references(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Insert(_))).count()
|
||||
}
|
||||
|
||||
/// Everything a refused BLOCK must leave alone: the objects, the blocks, the selection and the
|
||||
/// undo history.
|
||||
fn state(s: &Session) -> (Vec<String>, Vec<String>, Vec<String>, usize) {
|
||||
let d = s.doc().unwrap();
|
||||
let objects = d.model.iter().map(|e| format!("{:?}", e.kind)).collect();
|
||||
let blocks = d.blocks.keys().cloned().collect();
|
||||
let selection = s.selection().iter().map(|h| h.hex()).collect();
|
||||
(objects, blocks, selection, s.state().unwrap().undo.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_that_is_not_documented_is_an_error_and_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
let h = line(&mut s);
|
||||
s.set_selection(vec![Handle::parse_hex(h.as_str().unwrap()).unwrap()]);
|
||||
for (cmd, keep) in [("block", "retian"), ("block", ""), ("block", "erase"), ("-block", "convert-all"), ("-block", "keep")] {
|
||||
let before = state(&s);
|
||||
let err = s.execute(cmd, &json!({ "name": "B", "base": [0, 0], "handles": [h], "keep": keep })).unwrap_err();
|
||||
assert!(err.to_string().contains("`keep`"), "{cmd} {keep:?}: {err}");
|
||||
assert_eq!(state(&s), before, "{cmd} {keep:?} changed the drawing");
|
||||
assert!(s.doc().unwrap().block("B").is_none(), "{cmd} {keep:?} defined the block");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "the line is still there");
|
||||
assert_eq!(references(&s), 0);
|
||||
assert_eq!(s.selection().len(), 1, "the line is still selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_documented_values_work_in_any_case() {
|
||||
let mut s = Session::new();
|
||||
let h = line(&mut s);
|
||||
let r = s.execute("block", &json!({ "name": "R", "base": [0, 0], "handles": [h], "keep": "Retain" })).unwrap();
|
||||
assert!(r["insert"].is_null());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "the line stays");
|
||||
assert!(s.doc().unwrap().block("R").is_some());
|
||||
|
||||
let h = line(&mut s);
|
||||
let r = s.execute("block", &json!({ "name": "D", "base": [0, 0], "handles": [h], "keep": "DELETE" })).unwrap();
|
||||
assert!(r["insert"].is_null());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "the second line is erased");
|
||||
|
||||
let h = line(&mut s);
|
||||
let r = s.execute("-block", &json!({ "name": "C", "base": [0, 0], "handles": [h], "keep": " convert " })).unwrap();
|
||||
assert!(r["insert"].is_string());
|
||||
assert_eq!(references(&s), 1);
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2, "the retained line and C's reference");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! BREAK on a closed polyline removes the stretch from the first to the second point along the
|
||||
//! vertex direction and leaves one open polyline (#1173).
|
||||
|
||||
use cadcraft_doc::{EntityKind, LwPolyline};
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn polys(s: &Session) -> Vec<LwPolyline> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::LwPolyline(p) => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pts(p: &LwPolyline) -> Vec<(f64, f64)> {
|
||||
p.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()
|
||||
}
|
||||
|
||||
fn closed_rect(s: &mut Session) -> Value {
|
||||
s.execute("pline", &json!({ "points": [[0, 0], [100, 0], [100, 60], [0, 60]], "closed": true })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_opens_a_closed_polyline_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("PLINE 0,0 100,0 100,60 0,60 C").unwrap();
|
||||
s.cmdline("BREAK 50,0 100,30").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let ps = polys(&s);
|
||||
assert_eq!(ps.len(), 1);
|
||||
assert!(!ps[0].closed);
|
||||
assert_eq!(pts(&ps[0]), vec![(100.0, 30.0), (100.0, 60.0), (0.0, 60.0), (0.0, 0.0), (50.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_json_on_a_closed_polyline_follows_the_order_of_the_points() {
|
||||
let mut s = Session::new();
|
||||
let r = closed_rect(&mut s);
|
||||
// Second point before the first along the vertices: the long way round goes, the short
|
||||
// stretch between them stays.
|
||||
s.execute("break", &json!({ "handle": r["handle"], "p1": [100, 30], "p2": [50, 0] })).unwrap();
|
||||
let ps = polys(&s);
|
||||
assert_eq!(ps.len(), 1);
|
||||
assert!(!ps[0].closed);
|
||||
assert_eq!(pts(&ps[0]), vec![(50.0, 0.0), (100.0, 0.0), (100.0, 30.0)]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! BREAK and BREAKATPOINT keep the Z / elevation of the pieces they leave.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Arc, Common, Entity, EntityKind, Handle, Line, LwPolyline};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn add(s: &mut Session, kind: EntityKind) -> String {
|
||||
let space = s.space();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let handle = d.new_handle();
|
||||
d.space_mut(&space).unwrap().push(Entity { handle, common: Common::default(), kind });
|
||||
handle.hex()
|
||||
}
|
||||
|
||||
fn pieces(s: &Session, r: &Value) -> Vec<EntityKind> {
|
||||
let d = s.doc().unwrap();
|
||||
r["handles"].as_array().unwrap().iter().map(|h| d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().kind.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_keeps_line_z() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 5.0), b: Vec3::new(10.0, 0.0, 5.0) }));
|
||||
let r = s.execute("break", &json!({"handle": h, "p1": [4, 0], "p2": [6, 0]})).unwrap();
|
||||
let ps = pieces(&s, &r);
|
||||
assert_eq!(ps.len(), 2);
|
||||
for p in ps {
|
||||
let EntityKind::Line(l) = p else { panic!("not a line: {p:?}") };
|
||||
assert_eq!((l.a.z, l.b.z), (5.0, 5.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakatpoint_keeps_line_z() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 5.0), b: Vec3::new(10.0, 0.0, 5.0) }));
|
||||
let r = s.execute("breakatpoint", &json!({"handle": h, "at": [4, 0]})).unwrap();
|
||||
let ps = pieces(&s, &r);
|
||||
assert_eq!(ps.len(), 2);
|
||||
for p in ps {
|
||||
let EntityKind::Line(l) = p else { panic!("not a line: {p:?}") };
|
||||
assert_eq!((l.a.z, l.b.z), (5.0, 5.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_keeps_polyline_elevation() {
|
||||
let mut s = Session::new();
|
||||
let vertices = vec![PolyVertex::new(Vec2::new(0.0, 0.0)), PolyVertex::new(Vec2::new(10.0, 0.0))];
|
||||
let h = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 7.0, plinegen: false }));
|
||||
let r = s.execute("break", &json!({"handle": h, "p1": [4, 0], "p2": [6, 0]})).unwrap();
|
||||
let ps = pieces(&s, &r);
|
||||
assert_eq!(ps.len(), 2);
|
||||
for p in ps {
|
||||
let EntityKind::LwPolyline(pl) = p else { panic!("not a polyline: {p:?}") };
|
||||
assert_eq!(pl.elevation, 7.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_keeps_arc_centre_z() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Arc(Arc { center: Vec3::new(0.0, 0.0, 3.0), radius: 5.0, start: 0.0, end: std::f64::consts::PI }));
|
||||
let p1 = [5.0 * 0.5f64.sqrt(), 5.0 * 0.5f64.sqrt()];
|
||||
let r = s.execute("break", &json!({"handle": h, "p1": p1, "p2": [0, 5]})).unwrap();
|
||||
let ps = pieces(&s, &r);
|
||||
assert_eq!(ps.len(), 2);
|
||||
for p in ps {
|
||||
let EntityKind::Arc(a) = p else { panic!("not an arc: {p:?}") };
|
||||
assert_eq!(a.center.z, 3.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! BURST (Express Tools): a block reference explodes like EXPLODE, its visible attributes become
|
||||
//! TEXT with their values, its invisible ones and the block's attribute definitions go.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::Color;
|
||||
use cadcraft_engine::doc::{Entity, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Block B: attribute PART (on layer TAGS, height 1), NOTE (on layer 0, height 0.5), SECRET
|
||||
/// (invisible), a line and a reference to block N (a circle). One reference to B on layer R at
|
||||
/// `at`, scaled by `scale`, with values for all three.
|
||||
fn drawing(at: [f64; 2], scale: [f64; 2]) -> (Session, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({"name": "TAGS", "current": true})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "PART", "default": "p", "at": [0, 2], "height": 1.0})).unwrap();
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "NOTE", "default": "n", "at": [0, 4], "height": 0.5})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "SECRET", "default": "s", "at": [0, 6], "height": 1.0, "invisible": true})).unwrap();
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
s.execute("block", &json!({"name": "N", "base": [0, 0], "keep": "delete", "handles": [c["handle"]]})).unwrap();
|
||||
s.execute("insert", &json!({"name": "N", "at": [5, 5]})).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], "keep": "delete", "handles": hs})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap();
|
||||
let r = s
|
||||
.execute("insert", &json!({"name": "B", "at": at, "scale": scale, "attribs": {"PART": "P-7", "NOTE": "Hello", "SECRET": "hidden"}}))
|
||||
.unwrap();
|
||||
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
fn model(s: &Session) -> Vec<Entity> {
|
||||
s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect()
|
||||
}
|
||||
|
||||
/// (value, layer, x, y, height) of every TEXT in model space.
|
||||
fn texts(s: &Session) -> Vec<(String, String, f64, f64, f64)> {
|
||||
model(s)
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Text(t) => Some((t.value.clone(), e.common.layer.clone(), t.insert.x, t.insert.y, t.height)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lines(s: &Session) -> Vec<[f64; 4]> {
|
||||
model(s)
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some([l.a.x, l.a.y, l.b.x, l.b.y]),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn inserts(s: &Session) -> Vec<String> {
|
||||
model(s)
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn burst(s: &mut Session, h: Handle) -> Value {
|
||||
s.execute("burst", &json!({"handles": [h.hex()]})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_turns_visible_attribute_values_into_text() {
|
||||
let (mut s, r) = drawing([100.0, 0.0], [1.0, 1.0]);
|
||||
burst(&mut s, r);
|
||||
assert_eq!(lines(&s), vec![[100.0, 0.0, 110.0, 0.0]]);
|
||||
// The nested block reference stays a block reference, as EXPLODE leaves it (one level).
|
||||
assert_eq!(inserts(&s), ["N"]);
|
||||
assert!(!model(&s).iter().any(|e| matches!(e.kind, EntityKind::AttDef(_))));
|
||||
// The values, not the tags; the invisible attribute is gone. PART keeps its definition's
|
||||
// layer; NOTE was defined on layer 0, so it goes on the reference's layer.
|
||||
let mut t = texts(&s);
|
||||
t.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(t, [("Hello".into(), "R".into(), 100.0, 4.0, 0.5), ("P-7".into(), "TAGS".into(), 100.0, 2.0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_text_keeps_the_attribute_colour_and_explode_does_not() {
|
||||
let (mut s, r) = drawing([0.0, 0.0], [1.0, 1.0]);
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(r, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.attribs.iter_mut().filter(|a| a.tag == "NOTE").for_each(|a| a.props.color = Some(Color::Index(3)));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
burst(&mut s, r);
|
||||
let note = model(&s).into_iter().find(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Hello")).unwrap();
|
||||
assert_eq!(note.common.color, Color::Index(3));
|
||||
// EXPLODE gives the definitions (their tags) instead of the values.
|
||||
let (mut x, r) = drawing([0.0, 0.0], [1.0, 1.0]);
|
||||
x.execute("explode", &json!({"handles": [r.hex()]})).unwrap();
|
||||
assert!(texts(&x).is_empty());
|
||||
assert_eq!(model(&x).iter().filter(|e| matches!(e.kind, EntityKind::AttDef(_))).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_places_the_geometry_as_explode_does_for_a_non_uniformly_scaled_reference() {
|
||||
let (mut s, r) = drawing([10.0, 20.0], [2.0, 3.0]);
|
||||
let (mut x, _) = drawing([10.0, 20.0], [2.0, 3.0]);
|
||||
let EntityKind::Insert(ins) = &s.doc().unwrap().entity(r).unwrap().kind else { panic!("not a block reference") };
|
||||
let part = ins.attribs.iter().find(|a| a.tag == "PART").unwrap().text.clone();
|
||||
burst(&mut s, r);
|
||||
x.execute("explode", &json!({"handles": [r.hex()]})).unwrap();
|
||||
let geometry = |s: &Session| -> Vec<EntityKind> {
|
||||
model(s).into_iter().filter(|e| !matches!(e.kind, EntityKind::Text(_) | EntityKind::AttDef(_))).map(|e| e.kind).collect()
|
||||
};
|
||||
assert_eq!(geometry(&s), geometry(&x));
|
||||
// The text is the attribute as it was drawn: position, height, rotation, style.
|
||||
assert_eq!(part.insert.y, 26.0);
|
||||
assert!(model(&s).iter().any(|e| e.kind == EntityKind::Text(part.clone())), "{:?}", texts(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_is_one_undo_step_and_leaves_other_objects_alone() {
|
||||
let (mut s, r) = drawing([0.0, 0.0], [1.0, 1.0]);
|
||||
s.execute("line", &json!({"points": [[50, 50], [60, 50]]})).unwrap();
|
||||
let l = s.doc().unwrap().model.iter().last().unwrap().handle;
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
s.execute("burst", &json!({"handles": [r.hex(), l.hex()]})).unwrap();
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
|
||||
assert!(lines(&s).contains(&[50.0, 50.0, 60.0, 50.0]));
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(inserts(&s), ["B"]);
|
||||
assert!(texts(&s).is_empty());
|
||||
assert_eq!(lines(&s), vec![[50.0, 50.0, 60.0, 50.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_at_the_command_line_selects_then_bursts() {
|
||||
let (mut s, r) = drawing([0.0, 0.0], [1.0, 1.0]);
|
||||
s.cmdline("burst").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Pick(vec![r])).unwrap();
|
||||
s.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
assert_eq!(inserts(&s), ["N"]);
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! A dimension's text height, arrow size, overall scale and precision set from Properties
|
||||
//! (`properties.set`) are per-dimension overrides, as in AutoCAD's Properties palette (#1259).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Dimension, EntityKind};
|
||||
use cadcraft_engine::render::DimGeometry;
|
||||
use serde_json::json;
|
||||
|
||||
fn dims(s: &Session) -> Vec<(String, Dimension, DimGeometry)> {
|
||||
let d = s.doc().unwrap();
|
||||
d.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some((e.handle.hex(), dm.clone(), cadcraft_engine::render::dimension_in(d, dm))),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_override_one_dimensions_text_height_arrows_scale_and_precision() {
|
||||
let mut s = Session::new();
|
||||
s.script("dimlinear 0,0 10,0 5,-3\ndimlinear 0,10 20,10 5,13\n").unwrap();
|
||||
let before = dims(&s);
|
||||
let (h, _, g0) = &before[0];
|
||||
let base_height = g0.text_height;
|
||||
s.execute("properties.set", &json!({ "handles": [h], "textHeight": 5.0, "arrowSize": 0.0, "dimScale": 2.0, "precision": 3 })).unwrap();
|
||||
let after = dims(&s);
|
||||
let (_, dm, g) = &after[0];
|
||||
assert_eq!(dm.overrides.get("textHeight"), Some(&json!(5.0)));
|
||||
assert_eq!(dm.overrides.get("arrowSize"), Some(&json!(0.0)));
|
||||
assert_eq!(dm.overrides.get("scale"), Some(&json!(2.0)));
|
||||
assert_eq!(dm.overrides.get("decimals"), Some(&json!(3)));
|
||||
// The text is drawn 5 × the overall scale high and shows three decimals.
|
||||
assert!((g.text_height - 10.0).abs() < 1e-9, "{} (was {base_height})", g.text_height);
|
||||
assert_eq!(g.value, "10.000");
|
||||
// The other dimension is left alone.
|
||||
assert!(after[1].1.overrides.is_empty());
|
||||
assert!((after[1].2.text_height - before[1].2.text_height).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_change_nothing() {
|
||||
let mut s = Session::new();
|
||||
s.script("dimlinear 0,0 10,0 5,-3\n").unwrap();
|
||||
let h = dims(&s)[0].0.clone();
|
||||
for p in [json!({ "textHeight": 0.0 }), json!({ "dimScale": -1.0 }), json!({ "arrowSize": "big" }), json!({ "precision": 9 })] {
|
||||
let mut p = p;
|
||||
p["handles"] = json!([h]);
|
||||
assert!(s.execute("properties.set", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(dims(&s)[0].1.overrides.is_empty());
|
||||
}
|
||||
@@ -36,11 +36,10 @@ fn radial_text_goes_inside_when_the_location_is_inside() {
|
||||
assert!(covers(&g, 9.0) && !covers(&g, 4.0) && !covers(&g, 1.0));
|
||||
assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.len() <= 10.0 + 1e-9));
|
||||
|
||||
// Outside: the default placement beyond the arrowhead.
|
||||
// Outside: the text goes to the point too (dim_radial_outside.rs), beyond the arrowhead.
|
||||
s.script("dimradius 0,10 0,15\n").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let (dm, g) = last_dim(&s);
|
||||
assert!(!dm.user_text_pos);
|
||||
let (_, g) = last_dim(&s);
|
||||
assert!(g.text_pos.y > 10.0, "{:?}", g.text_pos);
|
||||
|
||||
// JSON form, diameter: the line spans the circle, broken around the text.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! DIMRADIUS / DIMDIAMETER: a dimension line location picked outside the circle puts the text
|
||||
//! at that point (group 11, flag 70 bit 128), as for a location inside; the leader stops short
|
||||
//! of the text instead of running into it.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Dimension, EntityKind};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::render::{DimGeometry, LineRole};
|
||||
use serde_json::json;
|
||||
|
||||
fn last_dim(s: &Session) -> (Dimension, DimGeometry) {
|
||||
let d = s.doc().unwrap();
|
||||
match &d.model.last().unwrap().kind {
|
||||
EntityKind::Dimension(dm) => (dm.clone(), cadcraft_engine::render::dimension_in(d, dm)),
|
||||
other => panic!("not a dimension: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Group 70 of every DIMENSION in the saved DXF.
|
||||
fn dxf_flags(s: &Session) -> Vec<i64> {
|
||||
let text = cadcraft_io::write_dxf(s.doc().unwrap());
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let mut flags = Vec::new();
|
||||
let mut in_dim = false;
|
||||
for p in lines.chunks(2) {
|
||||
match p {
|
||||
["0", v] => in_dim = *v == "DIMENSION",
|
||||
["70", v] if in_dim => flags.push(v.parse().unwrap()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
fn assert_text_at(s: &Session, at: Vec2) {
|
||||
assert!(s.running.is_none());
|
||||
let (dm, g) = last_dim(s);
|
||||
assert!(dm.user_text_pos);
|
||||
assert!(dm.text_mid.xy().dist(at) < 1e-9, "{:?}", dm.text_mid);
|
||||
assert!(g.text_pos.dist(at) < 1e-9, "{:?}", g.text_pos);
|
||||
let nearest = g.lines_of(LineRole::Dim).flatten().map(|p| p.dist(at)).fold(f64::INFINITY, f64::min);
|
||||
assert!(nearest > 0.05, "the leader runs into the text: {nearest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_text_goes_to_a_location_outside_the_circle() {
|
||||
let mut s = Session::new();
|
||||
s.script("circle 50,50 25\n").unwrap();
|
||||
s.script("dimradius 75,50 100,80\n").unwrap();
|
||||
assert_text_at(&s, Vec2::new(100.0, 80.0));
|
||||
|
||||
s.script("circle 300,50 25\n").unwrap();
|
||||
s.script("dimdiameter 325,50 350,80\n").unwrap();
|
||||
assert_text_at(&s, Vec2::new(350.0, 80.0));
|
||||
|
||||
s.execute("arc", &json!({"center": [50, 200], "radius": 25, "start": 0, "end": 90})).unwrap();
|
||||
s.script("dimradius 75,200 100,230\n").unwrap();
|
||||
assert_text_at(&s, Vec2::new(100.0, 230.0));
|
||||
|
||||
// Radius 4 | 32 | 128, diameter 3 | 32 | 128, as AutoCAD writes them.
|
||||
assert_eq!(dxf_flags(&s), vec![164, 163, 164]);
|
||||
}
|
||||
@@ -23,6 +23,11 @@ fn dxf(src: &str) -> String {
|
||||
/// A session with DOOR (Width's end grip 9 shown in the Open state, the visibility grip 10) and
|
||||
/// a reference to it at (100, 0).
|
||||
fn session() -> (Session, Handle) {
|
||||
session_with(|src| src)
|
||||
}
|
||||
|
||||
/// As [`session`], with the fixture's text changed by `edit` first.
|
||||
fn session_with(edit: impl Fn(String) -> String) -> (Session, Handle) {
|
||||
let grip = "0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n";
|
||||
let src = include_str!("../../io/tests/data/dynblock_door.txt")
|
||||
.replace("\r\n", "\n")
|
||||
@@ -31,7 +36,7 @@ fn session() -> (Session, Handle) {
|
||||
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68")
|
||||
.replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n")
|
||||
.replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP"));
|
||||
let d = cadcraft_io::read_dxf(dxf(&src).as_bytes()).unwrap();
|
||||
let d = cadcraft_io::read_dxf(dxf(&edit(src)).as_bytes()).unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "door", None);
|
||||
let r = s.execute("insert", &json!({ "name": "Door", "at": [100, 0] })).unwrap();
|
||||
@@ -88,3 +93,55 @@ fn a_grip_drag_previews_the_reference_and_takes_a_point_or_a_distance() {
|
||||
assert!(s.start_dyn_grip(h, 1, GripPart::At).is_err());
|
||||
assert!(s.start_dyn_grip(h, 2, GripPart::Base).is_err());
|
||||
}
|
||||
|
||||
/// Width with its base grip too (grip 8, shown in the Open state).
|
||||
fn with_base_grip(src: String) -> String {
|
||||
let base = "0 BLOCKLINEARGRIP\n5 69\n330 61\n100 AcDbEvalExpr\n90 8\n98 33\n99 232\n100 AcDbBlockElement\n300 Base Grip\n98 33\n99 232\n1071 0\n";
|
||||
src.replace("95 9\n360 68", "95 9\n360 68\n92 -1\n92 -1\n92 -1\n92 -1\n91 7\n93 32\n95 8\n360 69")
|
||||
.replace("95 2\n333 63\n333 68\n", "95 3\n333 63\n333 68\n333 69\n")
|
||||
.replace("0 BLOCKFLIPGRIP", &format!("{base}0 BLOCKFLIPGRIP"))
|
||||
}
|
||||
|
||||
fn parts(s: &mut Session, h: Handle) -> Vec<(i64, String)> {
|
||||
let g = s.execute("blocks.reference.grips", &json!({ "handle": h.hex() })).unwrap();
|
||||
g.as_array().unwrap().iter().map(|g| (g["node"].as_i64().unwrap(), g["part"].as_str().unwrap().to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_no_action_follows_has_no_grip() {
|
||||
// Width's stretch follows its end only: its base grip, though the block has one, moves
|
||||
// nothing and isn't shown (as AutoCAD shows the reference).
|
||||
let (mut s, h) = session_with(with_base_grip);
|
||||
assert_eq!(parts(&mut s, h), [(1, "at".to_string()), (2, "end".to_string())]);
|
||||
// A stretch that follows the base instead: the base grip, not the end's.
|
||||
let (mut s, h) = session_with(|src| with_base_grip(src).replace("301 EndXDelta\n302 EndYDelta", "301 BaseXDelta\n302 BaseYDelta"));
|
||||
assert_eq!(parts(&mut s, h), [(1, "at".to_string()), (2, "base".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_that_lists_a_parameter_shows_its_grips() {
|
||||
// Open lists Width but not its grip: the grip shows (as AutoCAD shows it); Closed lists
|
||||
// neither, and the grip doesn't.
|
||||
let (mut s, h) = session_with(|src| src.replace("95 2\n333 63\n333 68\n", "95 1\n333 63\n"));
|
||||
assert_eq!(parts(&mut s, h), [(1, "at".to_string()), (2, "end".to_string())]);
|
||||
s.execute("blocks.reference.set", &json!({ "handle": h.hex(), "name": "Visibility1", "value": "Closed" })).unwrap();
|
||||
assert_eq!(parts(&mut s, h), [(1, "at".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grip_drag_previews_the_reference_in_its_own_colours() {
|
||||
// The reference on layer Doors: the preview's block objects (on layer 0 in the block) draw on
|
||||
// Doors as the reference does, not in the preview colour; the rubber band is the current
|
||||
// layer's.
|
||||
let (mut s, h) = session();
|
||||
s.doc_mut().unwrap().modify_entity(h, |e| e.common.layer = "Doors".into()).unwrap();
|
||||
s.start_dyn_grip(h, 2, GripPart::End).unwrap();
|
||||
let preview = s.preview(Vec2::new(132.0, 0.0));
|
||||
// The block's objects first, then the rubber band (and any ticks) on the current layer, in
|
||||
// the grip blue so they show over the block's fills.
|
||||
let layers: Vec<&str> = preview.iter().map(|e| e.common.layer.as_str()).collect();
|
||||
let n = layers.iter().take_while(|l| **l == "Doors").count();
|
||||
assert!(n > 0 && n < layers.len() && layers[n..].iter().all(|l| *l == "0"), "{layers:?}");
|
||||
let blue = cadcraft_engine::doc::color::Color::True(cadcraft_engine::doc::color::Rgb(0x2f, 0x6b, 0xff));
|
||||
assert!(preview[n..].iter().all(|e| e.common.color == blue));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! A point held by GCFIX stays where it is when its grip is dragged (#1134): the Fix locks the
|
||||
//! position the point had, not wherever the edit left it.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn grip_drag(s: &mut Session, h: Handle, from: Vec2, to: &str) {
|
||||
s.set_selection(vec![h]);
|
||||
let i = s.grips_of(h).unwrap().iter().position(|g| g.near(from, 1e-9)).unwrap();
|
||||
s.start_grip_edit(&[(h, i)]).unwrap();
|
||||
s.cmdline(to).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_grip_of_a_fixed_end_leaves_it_in_place() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "start" })).unwrap();
|
||||
|
||||
grip_drag(&mut s, a, Vec2::new(0.0, 0.0), "3,4");
|
||||
let (start, end) = ends(&s, a);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-6), "the fixed start moved to {start:?}");
|
||||
assert!(end.near(Vec2::new(10.0, 0.0), 1e-6), "{end:?}");
|
||||
|
||||
// The free end still follows its grip.
|
||||
grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5");
|
||||
let (start, end) = ends(&s, a);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-6), "{start:?}");
|
||||
assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}");
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
//! HATCH and HATCHEDIT options (#451): HPGAPTOL gap tolerance, pattern origin, separate
|
||||
//! hatches, island style, linking and unlinking boundaries, recreated boundaries, -HATCHEDIT.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle, Hatch};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn hatch(s: &Session, h: &str) -> Hatch {
|
||||
@@ -122,6 +122,63 @@ fn hatchedit_needs_a_hatch() {
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [r], "scale": 2 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_adds_and_removes_boundary_loops() {
|
||||
let mut s = Session::new();
|
||||
let outer = rect(&mut s, [0.0, 0.0], [20.0, 20.0]);
|
||||
let island = rect(&mut s, [5.0, 5.0], [10.0, 10.0]);
|
||||
rect(&mut s, [30.0, 0.0], [40.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "handles": [outer] })).unwrap());
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "addBoundary": [island.clone()], "addPoints": [[35, 5]] })).unwrap();
|
||||
let edited = hatch(&s, &h);
|
||||
assert_eq!(edited.loops.len(), 3);
|
||||
assert_eq!(edited.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), vec![true, true, false]);
|
||||
assert!(edited.associative);
|
||||
assert_eq!(edited.loops[2].sources, vec![Handle::parse_hex(&island).unwrap()]);
|
||||
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "removeLoops": [2] })).unwrap();
|
||||
let edited = hatch(&s, &h);
|
||||
assert_eq!(edited.loops.len(), 2);
|
||||
assert!(edited.loops.iter().all(|l| l.outer));
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(hatch(&s, &h).loops.len(), 3, "one edit is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_added_object_boundary_stays_associative() {
|
||||
let mut s = Session::new();
|
||||
let outer = rect(&mut s, [0.0, 0.0], [20.0, 20.0]);
|
||||
let island = rect(&mut s, [5.0, 5.0], [10.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "handles": [outer] })).unwrap());
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "addBoundary": [island.clone()] })).unwrap();
|
||||
let edited = hatch(&s, &h);
|
||||
assert!(edited.associative, "{edited:?}");
|
||||
assert_eq!(edited.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), vec![true, false]);
|
||||
assert_eq!(edited.loops[1].sources, vec![Handle::parse_hex(&island).unwrap()]);
|
||||
s.execute("move", &json!({ "handles": [island], "delta": [1, 0] })).unwrap();
|
||||
assert_eq!(hatch(&s, &h).loops[1].vertices[0].p.x, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_refuses_invalid_boundary_changes_without_editing() {
|
||||
let mut s = Session::new();
|
||||
let outer = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "handles": [outer] })).unwrap());
|
||||
let before = hatch(&s, &h);
|
||||
for change in [
|
||||
json!({ "removeLoops": [0] }),
|
||||
json!({ "removeLoops": [9] }),
|
||||
json!({ "addBoundary": ["BAD-HANDLE"] }),
|
||||
json!({ "addPoints": [[f64::NAN, 1.0]] }),
|
||||
] {
|
||||
let mut p = change.as_object().unwrap().clone();
|
||||
p.insert("handles".into(), json!([h.clone()]));
|
||||
p.insert("scale".into(), json!(2.0));
|
||||
assert!(s.execute("hatchedit", &Value::Object(p)).is_err());
|
||||
assert_eq!(hatch(&s, &h), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_origin_and_separate_keywords_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
@@ -157,6 +214,26 @@ fn dash_hatchedit_options_at_the_command_line() {
|
||||
assert_eq!(hatch(&s, &h).loops[0].sources, vec![Handle::parse_hex(&r).unwrap()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_hatchedit_adds_and_removes_boundaries() {
|
||||
let mut s = Session::new();
|
||||
let outer = rect(&mut s, [0.0, 0.0], [20.0, 20.0]);
|
||||
let island = rect(&mut s, [5.0, 5.0], [10.0, 10.0]);
|
||||
let h = handle(&s.execute("hatch", &json!({ "handles": [outer] })).unwrap());
|
||||
let selected = Handle::parse_hex(&h).unwrap();
|
||||
s.set_selection(vec![selected]);
|
||||
s.start("hatchedit").unwrap();
|
||||
s.input(Input::Keyword("add Object".into())).unwrap();
|
||||
s.input(Input::Pick(vec![Handle::parse_hex(&island).unwrap()])).unwrap();
|
||||
assert_eq!(hatch(&s, &h).loops.len(), 2);
|
||||
|
||||
s.set_selection(vec![selected]);
|
||||
s.start("hatchedit").unwrap();
|
||||
s.input(Input::Keyword("remove Boundary".into())).unwrap();
|
||||
s.input(Input::Text("2".into())).unwrap();
|
||||
assert_eq!(hatch(&s, &h).loops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_refuses_unknown_patterns_and_region_boundaries() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -33,6 +33,13 @@ fn area_object_add_and_subtract_keep_a_running_total() {
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() {
|
||||
// Open, 160 long; the closing side back to 0,0 would add 116.619.
|
||||
let (_, log) = run(&["pline", "0,0", "100,0", "100,60", "", "area", "o", "50,0"]);
|
||||
assert!(has(&log, "Area = 3000.0000, Perimeter = 160.0000"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_unsupported_keywords_say_so_and_length_extends_the_last_segment() {
|
||||
let (s, log) = run(&["area", "0,0", "a"]);
|
||||
@@ -66,3 +73,33 @@ fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
|
||||
assert!(has(&log, "no area"), "{log:?}");
|
||||
assert!(!has(&log, "Area = "), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_typed_asks_for_the_option_then_the_points() {
|
||||
// Distance is the default option (#1103: typed, it used to fail asking for `p1`).
|
||||
let (s, log) = run(&["measuregeom", "", "0,0", "3,4"]);
|
||||
assert!(has(&log, "Distance = 5.0000"), "{log:?}");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
let (_, log) = run(&["circle 0,0 2", "measuregeom", "r", "2,0"]);
|
||||
assert!(has(&log, "Radius = 2.0000"), "{log:?}");
|
||||
|
||||
let (_, log) = run(&["measuregeom", "ar", "0,0", "4,0", "4,3", ""]);
|
||||
assert!(has(&log, "Area = 6.0000"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_an_ellipse_reports_its_exact_perimeter() {
|
||||
// References from the arithmetic-geometric mean (Gauss–Kummer), to 12 decimals. A closed-form
|
||||
// estimate gives 46.025625 for the 10 × 4 and 39.874135 for the 10 × 0.1.
|
||||
for (a, b, want) in
|
||||
[(10.0, 4.0, 46.026_225_191_330), (4.0, 2.0, 19.376_896_441_095), (10.0, 0.1, 40.010_983_297_227), (5.0, 5.0, 31.415_926_535_898)]
|
||||
{
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("ellipse", &json!({"center": [3, -2], "major": [0, a], "ratio": b / a})).unwrap();
|
||||
let got = s.execute("area", &json!({"handle": r["handle"]})).unwrap()["perimeter"].as_f64().unwrap();
|
||||
assert!((got - want).abs() < 1e-9 * want, "{a} x {b}: {got} vs {want}");
|
||||
}
|
||||
let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]);
|
||||
assert!(has(&log, "Perimeter = 46.0262"), "{log:?}");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! ID, DIST and LIST report Z: the Z of what is drawn at a picked point (a survey point, a 3D
|
||||
//! line, a contour polyline's elevation, a TIN face), and the Z of the objects LIST shows. They
|
||||
//! all said Z = 0, so a topographic drawing's levels couldn't be read in CADCraft.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
use serde_json::json;
|
||||
|
||||
fn add(s: &mut Session, k: EntityKind) -> Handle {
|
||||
s.add_entity(k).unwrap()
|
||||
}
|
||||
|
||||
fn z(s: &mut Session, at: [f64; 2]) -> f64 {
|
||||
s.execute("id", &json!({ "at": at })).unwrap()["z"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn contour(elevation: f64) -> EntityKind {
|
||||
EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: vec![PolyVertex::new(Vec2::new(0.0, 50.0)), PolyVertex::new(Vec2::new(40.0, 55.0)), PolyVertex::new(Vec2::new(80.0, 50.0))],
|
||||
closed: false,
|
||||
const_width: 0.0,
|
||||
elevation,
|
||||
plinegen: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_reports_the_z_of_what_is_at_the_point() {
|
||||
let mut s = Session::new();
|
||||
add(&mut s, EntityKind::Point(Point { p: Vec3::new(10.0, 10.0, 1883.546), angle: 0.0 }));
|
||||
add(&mut s, EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 10.0), b: Vec3::new(200.0, 0.0, 30.0) }));
|
||||
add(&mut s, contour(1880.0));
|
||||
// A sloped TIN face: z = 1000 + x / 10 over (300..400, 0..100).
|
||||
add(
|
||||
&mut s,
|
||||
EntityKind::Face3d(Face3d {
|
||||
corners: [Vec3::new(300.0, 0.0, 1030.0), Vec3::new(400.0, 0.0, 1040.0), Vec3::new(400.0, 100.0, 1040.0), Vec3::new(300.0, 100.0, 1030.0)],
|
||||
hidden_edges: 0,
|
||||
}),
|
||||
);
|
||||
s.cmdline("zoom e").ok();
|
||||
assert!((z(&mut s, [10.0, 10.0]) - 1883.546).abs() < 1e-9, "survey point");
|
||||
assert!((z(&mut s, [150.0, 0.0]) - 20.0).abs() < 1e-9, "middle of the 3D line");
|
||||
assert!((z(&mut s, [40.0, 55.0]) - 1880.0).abs() < 1e-9, "contour elevation");
|
||||
assert!((z(&mut s, [350.0, 0.0]) - 1035.0).abs() < 1e-9, "on the face's edge");
|
||||
// Nothing there: the current elevation.
|
||||
assert_eq!(z(&mut s, [-500.0, -500.0]), 0.0);
|
||||
s.doc_mut().unwrap().header.set_f64("ELEVATION", 7.5);
|
||||
assert_eq!(z(&mut s, [-500.0, -500.0]), 7.5);
|
||||
// A Z given with the point wins.
|
||||
assert_eq!(z_given(&mut s), 3.0);
|
||||
// At the command line it is in the message.
|
||||
s.cmdline("id").unwrap();
|
||||
s.cmdline("10,10").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Z = 1883.5460")), "{:?}", s.log.iter().rev().take(3).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
fn z_given(s: &mut Session) -> f64 {
|
||||
s.execute("id", &json!({ "at": [10, 10, 3] })).unwrap()["z"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirrored_contours_elevation_is_down_z() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, contour(25.0));
|
||||
s.doc_mut().unwrap().modify_entity(h, |e| e.common.extrusion = Vec3::new(0.0, 0.0, -1.0)).unwrap();
|
||||
s.cmdline("zoom e").ok();
|
||||
assert!((z(&mut s, [40.0, 55.0]) + 25.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dist_between_two_levels_gives_the_3d_distance_and_the_rise() {
|
||||
let mut s = Session::new();
|
||||
add(&mut s, EntityKind::Point(Point { p: Vec3::new(0.0, 0.0, 100.0), angle: 0.0 }));
|
||||
add(&mut s, EntityKind::Point(Point { p: Vec3::new(30.0, 40.0, 150.0), angle: 0.0 }));
|
||||
s.cmdline("zoom e").ok();
|
||||
let r = s.execute("dist", &json!({ "p1": [0, 0], "p2": [30, 40] })).unwrap();
|
||||
assert!((r["dz"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{r}");
|
||||
assert!((r["distance"].as_f64().unwrap() - (50.0f64 * 50.0 * 2.0).sqrt()).abs() < 1e-9, "{r}");
|
||||
assert!((r["angleFromXY"].as_f64().unwrap() - 45.0).abs() < 1e-9, "{r}");
|
||||
assert!(r["message"].as_str().unwrap().contains("Delta Z = 50.0000"), "{r}");
|
||||
// Flat points keep the 2D answer.
|
||||
let r = s.execute("dist", &json!({ "p1": [500, 500], "p2": [503, 504] })).unwrap();
|
||||
assert!((r["distance"].as_f64().unwrap() - 5.0).abs() < 1e-9 && r["dz"].as_f64().unwrap() == 0.0, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_shows_the_z_values_objects_have() {
|
||||
let mut s = Session::new();
|
||||
let l = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(30.0, 40.0, 60.0) }));
|
||||
let c = add(&mut s, contour(1880.0));
|
||||
let text = s.execute("list", &json!({ "handles": [l.hex(), c.hex()] })).unwrap()["message"].as_str().unwrap_or_default().to_string();
|
||||
let text = if text.is_empty() { s.log.join("\n") } else { text };
|
||||
assert!(text.contains("Z=10.0000") && text.contains("Z=60.0000"), "{text}");
|
||||
assert!(text.contains("Delta Z = 50.0000"), "{text}");
|
||||
assert!(text.contains("elevation 1880.0000") && text.contains("Z=1880.0000"), "{text}");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! JOIN with a single arc or elliptical arc offers cLose, which makes it the full circle or
|
||||
//! ellipse (#1175).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn h(r: &Value) -> String {
|
||||
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn kind(s: &Session, h: &str) -> EntityKind {
|
||||
s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn count(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_close_turns_a_single_arc_into_its_circle() {
|
||||
let mut s = Session::new();
|
||||
s.script("ARC C 50,50 80,50 50,80\nJOIN 80,50\n\n").unwrap();
|
||||
let p = s.current_prompt().expect("JOIN asks what to do with the single arc");
|
||||
assert!(p.keywords.iter().any(|k| k == "cLose"), "{p:?}");
|
||||
s.cmdline("L").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(count(&s), 1);
|
||||
match &s.doc().unwrap().model.iter().next().unwrap().kind {
|
||||
EntityKind::Circle(c) => {
|
||||
assert!(c.center.xy().near(cadcraft_engine::geom::Vec2::new(50.0, 50.0), 1e-9), "{c:?}");
|
||||
assert!((c.radius - 30.0).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
k => panic!("not a circle: {k:?}"),
|
||||
}
|
||||
// Undo brings the arc back.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().model.iter().next().unwrap().kind, EntityKind::Arc(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_close_turns_a_single_elliptical_arc_into_its_ellipse() {
|
||||
let mut s = Session::new();
|
||||
let a = h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap());
|
||||
s.script("JOIN 4,0\n\n").unwrap();
|
||||
let p = s.current_prompt().expect("JOIN asks what to do with the single elliptical arc");
|
||||
assert_eq!(p.display(), "Select elliptical arcs to join to source or [cLose]:");
|
||||
s.cmdline("cLose").unwrap();
|
||||
assert_eq!(count(&s), 1);
|
||||
match kind(&s, &a) {
|
||||
EntityKind::Ellipse(e) => {
|
||||
assert!(e.start.abs() < 1e-9 && (e.end - std::f64::consts::TAU).abs() < 1e-9, "{e:?}");
|
||||
assert!((e.ratio - 0.5).abs() < 1e-12 && (e.major.x - 4.0).abs() < 1e-12, "{e:?}");
|
||||
}
|
||||
k => panic!("not an ellipse: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_close_by_json_and_its_refusals() {
|
||||
let mut s = Session::new();
|
||||
let arc = h(&s.execute("arc", &json!({ "center": [50, 50], "radius": 30, "start": 0, "end": 90 })).unwrap());
|
||||
let r = s.execute("join", &json!({ "handles": [arc], "close": true })).unwrap();
|
||||
assert_eq!(h(&r), arc, "the arc keeps its handle");
|
||||
match kind(&s, &arc) {
|
||||
EntityKind::Circle(c) => assert!((c.radius - 30.0).abs() < 1e-9 && c.center.xy().near(cadcraft_engine::geom::Vec2::new(50.0, 50.0), 1e-9)),
|
||||
k => panic!("not a circle: {k:?}"),
|
||||
}
|
||||
// Close is for one arc or elliptical arc: a line, a circle or two objects are refused,
|
||||
// the drawing unchanged.
|
||||
let line = h(&s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap());
|
||||
let other = h(&s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap());
|
||||
let before = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>());
|
||||
for hs in [json!([line]), json!([arc]), json!([other, line])] {
|
||||
assert!(s.execute("join", &json!({ "handles": hs, "close": true })).is_err(), "{hs}");
|
||||
}
|
||||
assert_eq!(format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_close_prompt_takes_more_arcs_and_is_only_for_arcs() {
|
||||
// Arcs picked at the prompt join to the source as they would from the selection.
|
||||
let mut s = Session::new();
|
||||
s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap();
|
||||
s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 90, "end": 180 })).unwrap();
|
||||
s.script("JOIN 5,0\n\n").unwrap();
|
||||
s.cmdline("-5,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(count(&s), 1);
|
||||
// A single line has nothing to close: the command ends with the usual refusal.
|
||||
let mut s = Session::new();
|
||||
s.script("LINE 0,0 10,0\n\nJOIN 5,0\n\n").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert!(s.log.iter().any(|l| l == "Select at least two objects to join."), "{:?}", s.log);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! LAYOFF stays active until Enter or Esc (#1418): every pick turns off its object's layer and the
|
||||
//! prompt comes straight back, the way AutoCAD's Layer Off works.
|
||||
|
||||
use cadcraft_engine::doc::Handle;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::json;
|
||||
|
||||
/// The handles of the objects on `layer`.
|
||||
fn on_layer(s: &Session, layer: &str) -> Vec<Handle> {
|
||||
s.doc().unwrap().model.iter().filter(|e| e.common.layer == layer).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
/// Three lines, one on layer "A", one on "B" and one on "0" (the current layer).
|
||||
fn session_with_three_layers() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({ "name": "A", "current": true })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s.execute("layer.new", &json!({ "name": "B", "current": true })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 5], [10, 5]] })).unwrap();
|
||||
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn is_on(s: &Session, layer: &str) -> bool {
|
||||
s.doc().unwrap().layer(layer).unwrap().on
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layoff_keeps_asking_until_enter() {
|
||||
let mut s = session_with_three_layers();
|
||||
let undo_before = s.state().unwrap().undo.len();
|
||||
|
||||
s.start("layoff").unwrap();
|
||||
assert!(s.prompt_text().contains("layer to be turned off"), "{}", s.prompt_text());
|
||||
// The first pick turns off its layer's object and the command stays active (#1418).
|
||||
s.input(Input::Pick(on_layer(&s, "A"))).unwrap();
|
||||
assert!(s.running.is_some(), "LAYOFF is still running: {}", s.prompt_text());
|
||||
assert!(s.prompt_text().contains("layer to be turned off"), "it asks again: {}", s.prompt_text());
|
||||
assert!(!is_on(&s, "A"), "the picked object's layer is off");
|
||||
assert!(is_on(&s, "B"), "no other layer was touched");
|
||||
// The second pick turns off the second layer as well, without the command ending.
|
||||
s.input(Input::Pick(on_layer(&s, "B"))).unwrap();
|
||||
assert!(s.running.is_some(), "LAYOFF is still running: {}", s.prompt_text());
|
||||
assert!(!is_on(&s, "B"), "the second object's layer is off");
|
||||
assert!(is_on(&s, "0"), "the layer nothing was picked on is untouched");
|
||||
// Enter ends it, and the whole run is one undo step.
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none(), "Enter ends LAYOFF: {}", s.prompt_text());
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step for the whole run");
|
||||
s.undo().unwrap();
|
||||
assert!(is_on(&s, "A") && is_on(&s, "B"), "one undo takes both layers back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_ends_layoff_and_keeps_what_it_turned_off() {
|
||||
let mut s = session_with_three_layers();
|
||||
let undo_before = s.state().unwrap().undo.len();
|
||||
s.start("layoff").unwrap();
|
||||
s.input(Input::Pick(on_layer(&s, "A"))).unwrap();
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert!(s.running.is_none(), "Esc ends LAYOFF: {}", s.prompt_text());
|
||||
assert!(!is_on(&s, "A"), "the layer turned off before Esc stays off");
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step, the LAYOFF command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_pick_keeps_layoff_running() {
|
||||
let mut s = session_with_three_layers();
|
||||
s.start("layoff").unwrap();
|
||||
// A window that finds nothing is not a pick: the prompt stays and nothing is turned off.
|
||||
s.input(Input::Pick(Vec::new())).unwrap();
|
||||
assert!(s.running.is_some(), "{}", s.prompt_text());
|
||||
assert!(s.log.iter().any(|l| l.contains("0 found")), "{:?}", s.log);
|
||||
assert!(is_on(&s, "A") && is_on(&s, "B") && is_on(&s, "0"));
|
||||
s.input(Input::Pick(on_layer(&s, "A"))).unwrap();
|
||||
assert!(s.running.is_some() && !is_on(&s, "A"));
|
||||
}
|
||||
|
||||
/// A pickfirst selection still acts at once and ends the command, and the JSON form never prompts.
|
||||
#[test]
|
||||
fn pickfirst_and_json_calls_are_unchanged() {
|
||||
let mut s = session_with_three_layers();
|
||||
let a = on_layer(&s, "A");
|
||||
s.set_selection(a);
|
||||
s.start("layoff").unwrap();
|
||||
assert!(s.running.is_none(), "pickfirst: no prompt");
|
||||
assert!(!is_on(&s, "A"), "{:?}", s.log);
|
||||
|
||||
let mut s = session_with_three_layers();
|
||||
s.execute("layoff", &json!({ "handles": on_layer(&s, "B").iter().map(|h| h.hex()).collect::<Vec<_>>() })).unwrap();
|
||||
assert!(s.running.is_none(), "the JSON form must not prompt");
|
||||
assert!(!is_on(&s, "B"));
|
||||
}
|
||||
@@ -155,7 +155,30 @@ fn mledit_tees_and_corner() {
|
||||
s.execute("mledit", &json!({"tool": "ot", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
|
||||
assert_eq!(mline(&s, a).points(), vec![Vec2::new(6.0, 0.0), Vec2::new(10.0, 0.0)]);
|
||||
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
|
||||
assert!(s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).is_err());
|
||||
|
||||
// Merged tee: the outer elements stop at the second's near side, which opens, and the inner
|
||||
// ones run on to the second's inner element, which runs through.
|
||||
let mut s = Session::new();
|
||||
s.execute("mlstyle", &json!({"name": "C", "elements": [{"offset": 0.5}, {"offset": 0}, {"offset": -0.5}]})).unwrap();
|
||||
let (a, b) = crossing(&mut s);
|
||||
s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
|
||||
assert_eq!(mline(&s, a).points(), vec![Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0)]);
|
||||
assert_eq!(pieces(&mline(&s, a)), vec![[5.0, 0.0, 10.0, 0.0], [6.0, -1.0, 10.0, -1.0], [6.0, 1.0, 10.0, 1.0]]);
|
||||
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [5.0, -5.0, 5.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
|
||||
// Picked on the other side, from the command line.
|
||||
let mut s = Session::new();
|
||||
s.execute("mlstyle", &json!({"name": "C", "elements": [{"offset": 0.5}, {"offset": 0}, {"offset": -0.5}]})).unwrap();
|
||||
let (a, b) = crossing(&mut s);
|
||||
s.cmdline("mledit mt 1,0 5,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 4.0, -1.0], [0.0, 0.0, 5.0, 0.0], [0.0, 1.0, 4.0, 1.0]]);
|
||||
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, -1.0], [4.0, 1.0, 4.0, 5.0], [5.0, -5.0, 5.0, 5.0], [6.0, -5.0, 6.0, 5.0]]);
|
||||
// Two elements have nothing inner to merge: the same as the open tee.
|
||||
let mut s = Session::new();
|
||||
let (a, b) = crossing(&mut s);
|
||||
s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
|
||||
assert_eq!(mline(&s, a).points(), vec![Vec2::new(6.0, 0.0), Vec2::new(10.0, 0.0)]);
|
||||
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
|
||||
|
||||
// Corner joint: two multilines become one round the corner.
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! MOCORO (Express Tools Move/Copy/Rotate): select objects and a base point, then Move, Copy,
|
||||
//! Rotate, Scale, Base and Undo at one prompt until eXit.
|
||||
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
let r = s.execute("line", &json!({"points": [a, b]})).unwrap();
|
||||
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The end points of a line.
|
||||
fn ends(s: &Session, h: &str) -> ((f64, f64), (f64, f64)) {
|
||||
let e = s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone();
|
||||
match e {
|
||||
EntityKind::Line(l) => ((l.a.x, l.a.y), (l.b.x, l.b.y)),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every line in model space, by its end points.
|
||||
fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64))> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((l.a.x, l.a.y), (l.b.x, l.b.y))) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn near(a: ((f64, f64), (f64, f64)), b: ((f64, f64), (f64, f64))) -> bool {
|
||||
let c = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
c(a.0.0, b.0.0) && c(a.0.1, b.0.1) && c(a.1.0, b.1.0) && c(a.1.1, b.1.1)
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.state().unwrap().undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_carries_the_base_point_and_rotate_turns_about_it() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let undo = undo_len(&s);
|
||||
s.script("MOCORO ALL 0,0 M 10,0\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((10.0, 0.0), (20.0, 0.0))), "{:?} {:?}", ends(&s, &l), s.log);
|
||||
assert!(s.prompt_text().contains("Move"), "back at the option prompt: {}", s.prompt_text());
|
||||
// The base point moved with the objects: the turn is about 10,0.
|
||||
s.script("R 90\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((10.0, 0.0), (10.0, 10.0))), "{:?}", ends(&s, &l));
|
||||
s.script("X\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(undo_len(&s), undo + 1, "the whole command is one undo step");
|
||||
s.undo().unwrap();
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_leaves_the_originals_and_later_options_act_on_them() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.script("MOCORO ALL 0,0 C 0,5\n").unwrap();
|
||||
assert_eq!(lines(&s).len(), 2, "{:?}", s.log);
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))), "the original stays");
|
||||
// The next option moves the original from the same base point, not the copy.
|
||||
s.script("M 100,0 X\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((100.0, 0.0), (110.0, 0.0))), "{:?}", ends(&s, &l));
|
||||
assert!(lines(&s).iter().any(|x| near(*x, ((0.0, 5.0), (10.0, 5.0)))), "{:?}", lines(&s));
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_by_factor_or_point_and_refuses_a_zero_factor() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.script("MOCORO ALL 0,0 S 2\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (20.0, 0.0))), "{:?} {:?}", ends(&s, &l), s.log);
|
||||
// A factor given by a point is its distance from the base point.
|
||||
s.script("S 3,4\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (100.0, 0.0))), "{:?}", ends(&s, &l));
|
||||
// Zero is refused and the factor is asked again; Enter goes back to the option prompt.
|
||||
s.script("S 0\n").unwrap();
|
||||
assert!(s.prompt_text().contains("scale factor"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("Rotate"), "{}", s.prompt_text());
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (100.0, 0.0))));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "Enter at the option prompt is eXit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_follows_angdir() {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({"name": "ANGDIR", "value": 1})).unwrap();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.script("MOCORO ALL 0,0 R 90 X\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (0.0, -10.0))), "clockwise: {:?}", ends(&s, &l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_steps_back_one_option_and_restores_the_base_point() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.script("MOCORO ALL 0,0 M 10,0 C 0,5 R 90\n").unwrap();
|
||||
assert_eq!(lines(&s).len(), 2);
|
||||
s.script("U\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((10.0, 0.0), (20.0, 0.0))), "{:?}", ends(&s, &l));
|
||||
s.script("U\n").unwrap();
|
||||
assert_eq!(lines(&s).len(), 1, "the copy is undone");
|
||||
s.script("U\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))));
|
||||
s.script("U\n").unwrap();
|
||||
assert!(s.log.iter().any(|m| m.contains("Nothing to undo")), "{:?}", s.log);
|
||||
// The base point is back at 0,0 too.
|
||||
s.script("R 90 X\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (0.0, 10.0))), "{:?}", ends(&s, &l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_keeps_what_was_done_as_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let undo = undo_len(&s);
|
||||
s.script("MOCORO ALL 0,0 M 10,0 C 0,5 S\n").unwrap();
|
||||
assert!(s.prompt_text().contains("scale factor"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(lines(&s).len(), 2);
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(lines(&s).len(), 1);
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_at_the_option_prompt_is_not_an_option() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let undo = undo_len(&s);
|
||||
s.script("MOCORO ALL 0,0\n").unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
assert!(s.log.iter().any(|m| m.contains("Invalid option keyword")), "{:?}", s.log);
|
||||
assert!(s.running.is_some());
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(undo_len(&s), undo, "nothing done, no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_on_locked_layers_stay_put_but_can_be_copied() {
|
||||
let mut s = Session::new();
|
||||
let free = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("layer.new", &json!({"name": "L"})).unwrap();
|
||||
s.execute("layer.current", &json!({"name": "L"})).unwrap();
|
||||
let held = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
s.execute("layer.set", &json!({"name": "L", "locked": true})).unwrap();
|
||||
s.script("MOCORO ALL 0,0 M 1,0\n").unwrap();
|
||||
assert!(near(ends(&s, &free), ((1.0, 0.0), (11.0, 0.0))));
|
||||
assert!(near(ends(&s, &held), ((0.0, 5.0), (10.0, 5.0))));
|
||||
assert!(s.log.iter().any(|m| m.contains("1 object was on a locked layer")), "{:?}", s.log);
|
||||
s.script("X\n").unwrap();
|
||||
|
||||
// Everything locked: nothing moves and the command leaves no undo step.
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
|
||||
let undo = undo_len(&s);
|
||||
s.script("MOCORO ALL 0,0 M 1,0 R 90 X\n").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))));
|
||||
assert_eq!(undo_len(&s), undo, "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_runs_the_options_in_order() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let r = s
|
||||
.execute(
|
||||
"mocoro",
|
||||
&json!({"handles": [l], "base": [0, 0], "ops": [{"copy": [0, 5]}, {"move": [10, 0]}, {"rotate": 90}, {"base": [0, 0]}, {"scale": 2}]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["handles"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
// Moved to 10..20, turned about 10,0 to 10,0..10,10, scaled about 0,0.
|
||||
assert!(near(ends(&s, &l), ((20.0, 0.0), (20.0, 20.0))), "{:?}", ends(&s, &l));
|
||||
assert!(lines(&s).iter().any(|x| near(*x, ((0.0, 5.0), (10.0, 5.0)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_is_checked_before_the_drawing_changes() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let bad: [Value; 7] = [
|
||||
json!({"handles": [l], "ops": [{"move": [1, 0]}]}),
|
||||
json!({"handles": [l], "base": [0, 0]}),
|
||||
json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"scale": 0}]}),
|
||||
json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"rotate": "x"}]}),
|
||||
json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"spin": 3}]}),
|
||||
json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0], "copy": [0, 1]}]}),
|
||||
json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"scale": 1e-300}]}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("mocoro", &p).is_err(), "{p}");
|
||||
assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))), "{p} moved the line");
|
||||
assert_eq!(lines(&s).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_after_base_undoes_only_the_new_base_point() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.script("MOCORO ALL 0,0 M 10,0 B 50,50 U\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((10.0, 0.0), (20.0, 0.0))), "the move stays: {:?}", ends(&s, &l));
|
||||
// The base point is back where the move left it: the turn is about 10,0.
|
||||
s.script("R 90 X\n").unwrap();
|
||||
assert!(near(ends(&s, &l), ((10.0, 0.0), (10.0, 10.0))), "{:?}", ends(&s, &l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_on_objects_that_are_gone_record_nothing() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("erase", &json!({"handles": [l]})).unwrap();
|
||||
let undo = undo_len(&s);
|
||||
let r = s.execute("mocoro", &json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"copy": [0, 1]}, {"rotate": 90}]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert!(lines(&s).is_empty());
|
||||
assert_eq!(undo_len(&s), undo, "no empty undo step");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! The saved two-click preference disables drag windows without changing typed selection.
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Session, cmd::options};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn pickdrag_is_saved_reset_and_validated() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(options::profile(&s)["PICKDRAG"], 2);
|
||||
s.execute("options", &json!({"PICKDRAG":0})).unwrap();
|
||||
let saved = options::profile(&s);
|
||||
let mut restored = Session::new();
|
||||
options::restore(&mut restored, &saved);
|
||||
assert!(!restored.settings.pickdrag);
|
||||
for value in [json!(1), json!(-1), json!(3), json!("bad"), json!(null), json!([0]), json!(1e300)] {
|
||||
assert!(s.execute("options", &json!({"PICKDRAG":value})).is_err());
|
||||
assert_eq!(options::profile(&s), saved);
|
||||
}
|
||||
s.execute("options", &json!({"reset":true})).unwrap();
|
||||
assert!(s.settings.pickdrag);
|
||||
options::restore(&mut restored, &json!({"PICKDRAG":1}));
|
||||
assert!(!restored.settings.pickdrag, "unsupported saved modes are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_click_selection_works_with_drag_disabled() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points":[[0,0],[10,0]]})).unwrap();
|
||||
s.cmdline("PICKDRAG 0").unwrap();
|
||||
assert!(!s.begin_window(Vec2::new(-5.0, -5.0)));
|
||||
assert!(s.pending_window.is_none());
|
||||
s.idle_click(Vec2::new(-5.0, -5.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some());
|
||||
s.idle_click(Vec2::new(15.0, 5.0), false).unwrap();
|
||||
assert!(s.pending_window.is_none());
|
||||
assert_eq!(s.selection().len(), 1);
|
||||
s.set_selection(Vec::new());
|
||||
s.cmdline("ERASE W -5,-5 15,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.doc().unwrap().model.is_empty());
|
||||
s.execute("setvar", &json!({"name":"PICKDRAG","value":2})).unwrap();
|
||||
assert!(s.begin_window(Vec2::new(-5.0, -5.0)));
|
||||
}
|
||||
@@ -20,9 +20,85 @@ fn malformed_filters_are_errors() {
|
||||
json!({"limit": -1}),
|
||||
json!({"offset": "1"}),
|
||||
json!({"crossing": "yes"}),
|
||||
json!({"selected": "true"}),
|
||||
json!({"selected": 1}),
|
||||
json!({"selected": []}),
|
||||
json!({"selected": {}}),
|
||||
] {
|
||||
assert!(s.execute("entities", &p).is_err(), "{p} must be rejected");
|
||||
}
|
||||
let r = s.execute("entities", &json!({"window": [[-5, -5], [5, 5]]})).unwrap();
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_filter_combines_before_paging_without_changing_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.new", &json!({"name": "측정", "current": true})).unwrap();
|
||||
for x in [0, 10, 20] {
|
||||
s.execute("circle", &json!({"center": [x, 0], "radius": 2})).unwrap();
|
||||
}
|
||||
s.execute("line", &json!({"points": [[0, 0], [3, 4]]})).unwrap();
|
||||
let all = s.execute("entities", &json!({})).unwrap();
|
||||
let hs: Vec<_> = all["entities"].as_array().unwrap().iter().map(|e| e["handle"].clone()).collect();
|
||||
// Leave a redo entry to prove the query doesn't clear redo or create an undo entry.
|
||||
s.execute("circle", &json!({"center": [99, 99], "radius": 1})).unwrap();
|
||||
s.undo().unwrap();
|
||||
s.execute("select", &json!({"handles": [hs[0], hs[2], hs[3]]})).unwrap();
|
||||
let before = s.state().unwrap().clone();
|
||||
let report = s.execute("drawing.inspect", &json!({})).unwrap();
|
||||
let selected = s.execute("entities", &json!({"selected": true})).unwrap();
|
||||
assert_eq!(selected["count"], 3);
|
||||
assert_eq!(selected["entities"][0]["handle"], hs[0]);
|
||||
assert_eq!(selected["entities"][1]["handle"], hs[2]);
|
||||
assert_eq!(s.execute("entities", &json!({"selected": false})).unwrap(), all);
|
||||
assert_eq!(s.execute("entities", &json!({"selected": null})).unwrap(), all);
|
||||
assert_eq!(s.execute("entities", &json!({})).unwrap(), all);
|
||||
let page = s.execute("entities", &json!({"selected": true, "type": "circle", "layer": "측정", "limit": 1, "offset": 1})).unwrap();
|
||||
assert_eq!(page, json!({"count": 2, "returned": 1, "offset": 1, "entities": [all["entities"][2]]}));
|
||||
let window = json!({"selected": true, "type": "circle", "layer": "측정", "window": [[-3, -3], [3, 3]], "crossing": false});
|
||||
assert_eq!(s.execute("entities", &window).unwrap()["count"], 1);
|
||||
assert_eq!(s.execute("entities", &json!({"selected": true, "layer": "other"})).unwrap()["count"], 0);
|
||||
assert_eq!(s.execute("entities", &json!({"selected": true, "limit": 0})).unwrap()["returned"], 0);
|
||||
assert_eq!(s.execute("entities", &json!({"selected": true, "offset": 99})).unwrap()["returned"], 0);
|
||||
assert!(s.execute("entities", &json!({"selected": "true"})).is_err());
|
||||
assert_eq!(s.execute("drawing.inspect", &json!({})).unwrap(), report);
|
||||
let after = s.state().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc));
|
||||
assert!(std::sync::Arc::ptr_eq(&before.saved, &after.saved));
|
||||
assert_eq!(before.revision, after.revision);
|
||||
assert_eq!(before.selection, after.selection);
|
||||
assert_eq!(before.previous_selection, after.previous_selection);
|
||||
assert_eq!(before.is_dirty(), after.is_dirty());
|
||||
for (a, b) in [(&before.undo, &after.undo), (&before.redo, &after.redo)] {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
assert_eq!(a.label, b.label);
|
||||
assert_eq!(a.selection, b.selection);
|
||||
assert!(std::sync::Arc::ptr_eq(&a.doc, &b.doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_empty_by_default_and_limited_to_active_space() {
|
||||
use cadcraft_doc::{Handle, Space};
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap();
|
||||
let model = s.doc().unwrap().model.iter().next().unwrap().handle;
|
||||
assert_eq!(s.execute("entities", &json!({"selected": true})).unwrap()["count"], 0);
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.execute("circle", &json!({"center": [10, 10], "radius": 2})).unwrap();
|
||||
let paper = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().last().unwrap().handle;
|
||||
// Even stale, duplicate and foreign-space selection handles cannot leak entities.
|
||||
s.state_mut().unwrap().selection = vec![model, paper, paper, Handle(u64::MAX)];
|
||||
let page = s.execute("entities", &json!({"selected": true})).unwrap();
|
||||
assert_eq!(page["count"], 1);
|
||||
assert_eq!(page["entities"][0]["handle"], paper.hex());
|
||||
s.state_mut().unwrap().space = Space::Model;
|
||||
let page = s.execute("entities", &json!({"selected": true})).unwrap();
|
||||
assert_eq!(page["count"], 1);
|
||||
assert_eq!(page["entities"][0]["handle"], model.hex());
|
||||
s.state_mut().unwrap().selection.clear();
|
||||
assert_eq!(s.execute("entities", &json!({"selected": true})).unwrap()["entities"], json!([]));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! The "other corner" of RECTANG and of ARRAYRECT's unit cell ignores ortho: the rectangle keeps
|
||||
//! its area (#1113).
|
||||
|
||||
use cadcraft_engine::snap::tracking::CursorQuery;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// Move the cursor to `raw` at the current prompt, as the canvas does.
|
||||
fn hover(s: &mut Session, raw: Vec2) -> Vec2 {
|
||||
let p = s.current_prompt().unwrap();
|
||||
let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false };
|
||||
let c = s.snap_cursor(raw, &q).point;
|
||||
s.cursor = c;
|
||||
c
|
||||
}
|
||||
|
||||
/// Draw a rectangle from 0,0 to a click at 10,3 and return its extents and the preview's.
|
||||
fn click_rectangle(ortho: bool) -> ((Vec2, Vec2), (Vec2, Vec2)) {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": ortho as i32 })).unwrap();
|
||||
s.cmdline("rectang 0,0").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify other corner point");
|
||||
let c = hover(&mut s, Vec2::new(10.0, 3.0));
|
||||
let mut pv = cadcraft_geom::Bounds2::EMPTY;
|
||||
for e in s.preview(c) {
|
||||
pv = pv.union(&cadcraft_doc::entity_bounds(s.doc().unwrap(), &e, 0));
|
||||
}
|
||||
s.input(Input::Point(c)).unwrap();
|
||||
assert!(s.current_prompt().is_none(), "RECTANG is done, still at {:?}", s.current_prompt().map(|p| p.message));
|
||||
let b = s.doc().unwrap().extents(&s.space());
|
||||
((b.min, b.max), (pv.min, pv.max))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectang_other_corner_ignores_ortho() {
|
||||
let want = (Vec2::ZERO, Vec2::new(10.0, 3.0));
|
||||
let on = click_rectangle(true);
|
||||
assert_eq!(on.0, want, "the rectangle is 10 by 3 with ortho on");
|
||||
assert_eq!(on.1, want, "the preview is 10 by 3 with ortho on");
|
||||
assert_eq!(click_rectangle(false), on, "ortho changes nothing");
|
||||
}
|
||||
|
||||
/// ARRAYRECT's unit cell from 0,0 to a click at 4,2 on a 2 by 2 array of a circle: the extents.
|
||||
fn click_unit_cell(ortho: bool) -> (Vec2, Vec2) {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": ortho as i32 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 0.5 })).unwrap();
|
||||
for t in ["arrayrect", "l", "", "cou", "2", "2", "s", "u", "0,0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify other corner");
|
||||
let c = hover(&mut s, Vec2::new(4.0, 2.0));
|
||||
s.input(Input::Point(c)).unwrap();
|
||||
s.cmdline("x").unwrap();
|
||||
assert!(s.current_prompt().is_none(), "ARRAYRECT is done, still at {:?}", s.current_prompt().map(|p| p.message));
|
||||
let b = s.doc().unwrap().extents(&s.space());
|
||||
(b.min, b.max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_unit_cell_ignores_ortho() {
|
||||
let on = click_unit_cell(true);
|
||||
assert_eq!(on, (Vec2::new(-0.5, -0.5), Vec2::new(4.5, 2.5)), "columns 4 apart, rows 2 apart with ortho on");
|
||||
assert_eq!(click_unit_cell(false), on, "ortho changes nothing");
|
||||
}
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user