mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-22 10:14:01 -04:00
Compare commits
256
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdcd9610f2 | ||
|
|
54d96834d5 | ||
|
|
47a8fb02e7 | ||
|
|
896a390b42 | ||
|
|
345eb60ef3 | ||
|
|
0cf3bdea83 | ||
|
|
7fef8cbe2f | ||
|
|
f2b473dc51 | ||
|
|
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 | ||
|
|
ea6d7beca2 | ||
|
|
9b2308b138 | ||
|
|
69fc442ea6 | ||
|
|
be3ce73895 | ||
|
|
fc4e98315a | ||
|
|
e707ab4d7c | ||
|
|
8cd0d46d58 | ||
|
|
1e075f76c5 | ||
|
|
7fc29b056e | ||
|
|
aed2ae9615 | ||
|
|
033ee15e01 | ||
|
|
4a9dd31a58 | ||
|
|
26179f7428 | ||
|
|
261d46adcf | ||
|
|
e74b276dfd | ||
|
|
d826450e03 | ||
|
|
abe352564e | ||
|
|
d80920cc25 | ||
|
|
43972c82e8 | ||
|
|
767576ec9e | ||
|
|
62fb5ae79a | ||
|
|
3199de4951 | ||
|
|
48352f2baf | ||
|
|
0d138c2954 | ||
|
|
f8521ab299 | ||
|
|
73b8edac61 | ||
|
|
fbc7037a10 | ||
|
|
e773d93164 | ||
|
|
a2b6c9ac4a | ||
|
|
8abc0909f3 | ||
|
|
8166e4a809 | ||
|
|
dc92f38772 | ||
|
|
1c4d0a543f | ||
|
|
ef9898d57f | ||
|
|
33fe98a6dc | ||
|
|
5630529464 | ||
|
|
3b16f2d516 | ||
|
|
2f5bbbd68d | ||
|
|
76b0ef8897 | ||
|
|
966356545d | ||
|
|
4257674dfa | ||
|
|
64b7825d08 | ||
|
|
d2a9159e51 | ||
|
|
678ff555ae | ||
|
|
d0438577ed | ||
|
|
63b448bee9 | ||
|
|
7b767ddab9 | ||
|
|
79efd97515 | ||
|
|
cddcbe7a2a | ||
|
|
b209bb53e9 | ||
|
|
5d39f8710c | ||
|
|
6394c7c6f3 | ||
|
|
2483993218 | ||
|
|
cc57dff527 | ||
|
|
724c18c6af | ||
|
|
8e0b867f6c | ||
|
|
36f938e90a | ||
|
|
636b041a4e | ||
|
|
bb7e1046be | ||
|
|
f807e65e30 | ||
|
|
69f2b9724a | ||
|
|
28b8160407 | ||
|
|
5b037b8f92 | ||
|
|
5fd23f75fc | ||
|
|
a6200fa34f | ||
|
|
25e4900fcd | ||
|
|
4efb01332e | ||
|
|
d134079f11 | ||
|
|
591692a264 | ||
|
|
50246683c3 | ||
|
|
2effc8bf3e | ||
|
|
5b0238fc26 | ||
|
|
7b723b7bc0 | ||
|
|
a10e43cb2a | ||
|
|
7e38081b54 | ||
|
|
3f709bed2e | ||
|
|
caad180e98 | ||
|
|
80ec2dcc60 | ||
|
|
46ab40d211 | ||
|
|
9464945f82 | ||
|
|
3dd678ff3d | ||
|
|
ea768ee92a | ||
|
|
5ce44acfb2 | ||
|
|
853b2915b6 | ||
|
|
0b92b59fcd | ||
|
|
f26588a357 | ||
|
|
8073453d86 | ||
|
|
89c834155d | ||
|
|
14343212a9 | ||
|
|
ecdb8f96b2 | ||
|
|
4c63ba1470 | ||
|
|
c0335161c3 | ||
|
|
419d8848d6 | ||
|
|
c781169b95 | ||
|
|
f314d3a591 | ||
|
|
e9d8462828 | ||
|
|
6ab800e64c | ||
|
|
565cadaa51 | ||
|
|
bca698a588 | ||
|
|
5de2324973 | ||
|
|
266ee7e6ea | ||
|
|
045212139a | ||
|
|
6772150bc4 | ||
|
|
b2ba585251 | ||
|
|
7fcdc6e3bc | ||
|
|
b19188919d | ||
|
|
488308a2de | ||
|
|
f5d58913cc | ||
|
|
5ce1ea3dfc | ||
|
|
483a9304ca | ||
|
|
5183f63bc5 | ||
|
|
bcd6322ee9 | ||
|
|
e71e17a8df | ||
|
|
08cfbd5dd7 | ||
|
|
00c7613f1a | ||
|
|
0ecffdab4a | ||
|
|
4cb9e8a535 | ||
|
|
a12c85c585 | ||
|
|
890f7f1c6f | ||
|
|
4bb3acb913 | ||
|
|
0675648de8 | ||
|
|
c2f8782cc2 | ||
|
|
d365b60704 | ||
|
|
cf797cbafe | ||
|
|
935cb98eed | ||
|
|
0178858e79 | ||
|
|
875f99d26f | ||
|
|
69ea207617 | ||
|
|
546eefc58a | ||
|
|
61c018845a | ||
|
|
9a0d1b3a62 | ||
|
|
507c5e5b34 | ||
|
|
bc6dc997ef | ||
|
|
6ca8cec97d | ||
|
|
f8cb50eaa3 | ||
|
|
b6f2e86f78 | ||
|
|
c57879a5c8 | ||
|
|
2404719c3b | ||
|
|
90cd443000 | ||
|
|
ac9483c9c1 | ||
|
|
ade5469a24 | ||
|
|
6d7c652b65 | ||
|
|
3aeedf4856 | ||
|
|
f7c6293d09 | ||
|
|
89064fd82a | ||
|
|
1e1f68bf40 | ||
|
|
244feb6e89 | ||
|
|
135c928fea | ||
|
|
9477f37503 | ||
|
|
370150186b | ||
|
|
2fa967db65 | ||
|
|
4e276db858 | ||
|
|
9b3674e01f | ||
|
|
c5ebf4d1cd | ||
|
|
83b8a84050 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -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,23 @@
|
||||
# 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:
|
||||
nix:
|
||||
# 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/nix.yml@main
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -34,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -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
|
||||
@@ -60,3 +60,14 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
|
||||
| `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
+137
-22
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6"
|
||||
checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"itoa",
|
||||
"memmap2",
|
||||
"nom",
|
||||
"oem_cp",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"ryu",
|
||||
@@ -45,7 +46,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -433,6 +434,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "basic-udev"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -540,25 +547,29 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -569,14 +580,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -586,7 +597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -597,14 +608,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -613,7 +624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -623,6 +634,7 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"log",
|
||||
"rstar",
|
||||
"serde",
|
||||
@@ -632,7 +644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
@@ -642,15 +654,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
@@ -659,14 +672,16 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"miniz_oxide 0.8.9",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -677,23 +692,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"lyon_tessellation",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-ui",
|
||||
@@ -710,7 +728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -911,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"
|
||||
@@ -1390,6 +1413,12 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float_next_after"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1742,6 +1771,21 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "2.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
|
||||
dependencies = [
|
||||
"basic-udev",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"nix",
|
||||
"pkg-config",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
@@ -2119,6 +2163,38 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lyon_geom"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_path"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
|
||||
dependencies = [
|
||||
"lyon_geom",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_tessellation"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005"
|
||||
dependencies = [
|
||||
"float_next_after",
|
||||
"lyon_path",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -2272,6 +2348,18 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -2631,6 +2719,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oem_cp"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c7959d3349f484aec36462f98226983578bf7d33f9b034c32967489c7000e1f"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2724,6 +2821,15 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -2731,7 +2837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -2742,7 +2848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2752,12 +2858,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@@ -4696,7 +4811,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.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"
|
||||
@@ -34,6 +36,7 @@ rayon = "1"
|
||||
|
||||
# rendering / formats
|
||||
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
lyon_tessellation = "1.0.22"
|
||||
skrifa = "0.47"
|
||||
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
|
||||
# and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
@@ -41,6 +44,8 @@ harfrust = "0.12"
|
||||
unicode-bidi = "0.3.18"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
quick-xml = "0.41"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
|
||||
@@ -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>
|
||||
@@ -26,6 +26,9 @@
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-0b6f88?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-14a3c7?style=flat-square">
|
||||
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f07a3a?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_cadcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/cadcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -111,7 +114,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 (Українська) and German (Deutsch), 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 +228,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
|
||||
@@ -252,8 +258,25 @@ have accessible labels and visible keyboard focus. Property controls remain appl
|
||||
|
||||
## Accessibility
|
||||
|
||||
### System cursor
|
||||
|
||||
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
|
||||
|
||||
### Animation effects
|
||||
|
||||
CADCraft follows the operating system's animation setting. When the system is set to show no animations, dialogs, menus and tooltips appear at once instead of fading in, collapsing sections and scroll bars change at once, and the mouse wheel scrolls lists a whole step at a time instead of gliding. Zooming the drawing with the wheel always moves a whole step; with Ctrl held (a touchpad pinch, on Windows) it glides to the new zoom only while animation effects show.
|
||||
|
||||
| System | Where the setting is |
|
||||
|---|---|
|
||||
| Windows | Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects |
|
||||
| macOS | System Settings ▸ Accessibility ▸ Reduce motion |
|
||||
| Linux, BSD | the desktop's reduced-motion (or animations) setting, read through the XDG Desktop Portal |
|
||||
| Web | the browser's `prefers-reduced-motion` |
|
||||
|
||||
A change to the system setting is picked up while CADCraft runs, within a few seconds. To choose for CADCraft alone, use **View ▸ Accessibility ▸ Animation Effects**: **Use System Setting** (the default), **On** or **Off**. Options ▸ Display has the same choice. Agents can set it with the control channel: `ui.set {"animations": "off"}` (`"system"`, `"on"` or `"off"`), and `ui.inspect` reports whether animations show (`animations`).
|
||||
|
||||
Drafting feedback is not an animation effect and stays as it is: rubber bands and previews, snap markers, the pause that acquires an object snap tracking point, the text cursor's blink, the spinner shown while a drawing opens, and pan and zoom, which follow the pointer and the wheel directly.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
|
||||
@@ -377,8 +400,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>
|
||||
|
||||
+10
-3
@@ -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
|
||||
@@ -10,7 +10,7 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
|
||||
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
@@ -94,11 +94,12 @@ 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% |
|
||||
|
||||
Other languages shipped: Ukrainian and German, both partial (989 catalog rows each; Ukrainian landed with #36; engine messages stay English).
|
||||
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -118,6 +119,9 @@ 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. |
|
||||
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
@@ -131,6 +135,9 @@ 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 |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
|
||||
|
||||
+121
-13
@@ -5,8 +5,9 @@
|
||||
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
|
||||
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
|
||||
//! cadcraft-cli commands [FILTER] command catalog (JSON)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
|
||||
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
|
||||
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -59,8 +60,8 @@ const USAGE: &str = "usage:
|
||||
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
|
||||
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
|
||||
cadcraft-cli commands [FILTER]
|
||||
cadcraft-cli mcp [--connect HOST:PORT]
|
||||
cadcraft-cli perf [N]
|
||||
cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
|
||||
cadcraft-cli perf [N | FILE]
|
||||
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
|
||||
cadcraft-cli --version
|
||||
|
||||
@@ -171,6 +172,11 @@ fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &str) -> Result<(), String> {
|
||||
@@ -266,10 +272,48 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
check_framed_output(framing, Some(o))?;
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, i)?;
|
||||
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));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
|
||||
let records = if n == 1 { "record" } else { "records" };
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
let mut it = args.iter();
|
||||
@@ -393,11 +437,55 @@ fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, Strin
|
||||
Ok(dr)
|
||||
}
|
||||
|
||||
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
|
||||
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
|
||||
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
|
||||
fn perf_file(path: &str) -> Result<(), String> {
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::render::{Options, build};
|
||||
use std::time::Instant;
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut s = Session::empty();
|
||||
let t = Instant::now();
|
||||
open(&mut s, path)?;
|
||||
let open_ms = ms(t);
|
||||
let st = s.state().map_err(|e| e.to_string())?;
|
||||
let (d, space) = (&st.doc, &st.space);
|
||||
let ext = d.extents(space);
|
||||
let size = ext.width().max(ext.height());
|
||||
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
|
||||
return Err("perf: the drawing is empty".into());
|
||||
}
|
||||
outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height());
|
||||
outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims");
|
||||
let mut finest = Options::default();
|
||||
for k in 0..5 {
|
||||
let zoom = 4f64.powi(k);
|
||||
let h = size / zoom;
|
||||
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
|
||||
let half = Vec2::new(h * 0.8, h * 0.5);
|
||||
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
|
||||
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let n = build(d, space, &screen).prims.len();
|
||||
outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t));
|
||||
finest = screen;
|
||||
}
|
||||
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
|
||||
let t = Instant::now();
|
||||
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
|
||||
outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perf(args: &[String]) -> Result<(), String> {
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{select, snap};
|
||||
use std::time::Instant;
|
||||
if let Some(path) = args.first().filter(|a| a.parse::<usize>().is_err()) {
|
||||
return perf_file(path);
|
||||
}
|
||||
let n: usize = match args.first() {
|
||||
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
|
||||
None => 200_000,
|
||||
@@ -479,37 +567,47 @@ fn perf(args: &[String]) -> Result<(), String> {
|
||||
enum McpTarget {
|
||||
/// An in-process drawing session (no `--connect`).
|
||||
Headless,
|
||||
/// The running app's control channel at `HOST:PORT`.
|
||||
Connect(String),
|
||||
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
|
||||
/// `launch`: opened then if it isn't running.
|
||||
Connect { addr: String, lazy: bool, launch: bool },
|
||||
}
|
||||
|
||||
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
|
||||
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
|
||||
let mut target = McpTarget::Headless;
|
||||
let mut addr: Option<String> = None;
|
||||
let (mut lazy, mut launch) = (false, false);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--connect" => {
|
||||
let addr = it
|
||||
let a = it
|
||||
.next()
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty() && !v.starts_with('-'))
|
||||
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
|
||||
if target != McpTarget::Headless {
|
||||
if addr.is_some() {
|
||||
return Err("--connect given more than once".into());
|
||||
}
|
||||
target = McpTarget::Connect(addr.to_string());
|
||||
addr = Some(a.to_string());
|
||||
}
|
||||
"--lazy" => lazy = true,
|
||||
"--launch" => launch = true,
|
||||
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
|
||||
}
|
||||
}
|
||||
Ok(target)
|
||||
match addr {
|
||||
// `--launch` connects at the first call too (the app opens then).
|
||||
Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }),
|
||||
None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()),
|
||||
None => Ok(McpTarget::Headless),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
|
||||
McpTarget::Connect(addr) => match cadcraft_mcp::Remote::connect(&addr) {
|
||||
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
|
||||
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
|
||||
},
|
||||
@@ -535,8 +633,14 @@ mod mcp_args_tests {
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_with_address_selects_remote() {
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), Ok(McpTarget::Connect("127.0.0.1:7979".into())));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), Ok(McpTarget::Connect("localhost:7979".into())));
|
||||
let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch });
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false));
|
||||
assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true));
|
||||
// Without an app to reach they mean nothing: never a headless session.
|
||||
assert!(parse_mcp_args(&args(&["--lazy"])).is_err());
|
||||
assert!(parse_mcp_args(&args(&["--launch"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -586,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,44 @@
|
||||
//! `info` reports the records a DXF file lost on reading, by section, and `convert` warns about
|
||||
//! them before writing the copy (#865). 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 after the last ENDSEC and a VERTEX with no POLYLINE before it.
|
||||
const DAMAGED: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n0\nVERTEX\n8\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nLINE\n8\n0\n10\n9\n20\n9\n11\n8\n21\n8\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn info_counts_and_convert_warns_about_skipped_records() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-skipped-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let damaged = dir.join("damaged.dxf");
|
||||
std::fs::write(&damaged, DAMAGED).unwrap();
|
||||
let damaged = damaged.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", damaged]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["skipped"], serde_json::json!({ "count": 2, "sections": { "(no section)": 1, "ENTITIES": 1 } }));
|
||||
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", damaged, copy.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("could not be read")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{err}");
|
||||
assert!(warnings[0].contains("2 records") && warnings[0].contains("ENTITIES: 1") && warnings[0].contains("(no section): 1"), "{err}");
|
||||
|
||||
// An intact file: a zero count, and no warning.
|
||||
let sample = dir.join("sample.dxf");
|
||||
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
|
||||
let out = cli(&["info", sample.to_str().unwrap()]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["skipped"], serde_json::json!({ "count": 0, "sections": {} }));
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("could not be read"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
@@ -23,14 +23,16 @@ mod web {
|
||||
const CANVAS_ID: &str = "cadcraft_canvas";
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, and the
|
||||
/// page embedding it (`?host=parent`).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>);
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, the
|
||||
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
@@ -80,6 +82,11 @@ mod web {
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
|
||||
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
|
||||
// files reach the user as browser downloads.
|
||||
@@ -124,7 +131,9 @@ mod web {
|
||||
if !crate::wants_cpu(&query())
|
||||
&& let Some(rs) = &cc.wgpu_render_state
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
// eframe's web runner has no stencil buffer: clipped layout
|
||||
// viewports stay in the layout's display list.
|
||||
app.set_wgpu(rs, None);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
let _ = app.run("ui.sample", serde_json::json!({}));
|
||||
@@ -142,7 +151,8 @@ mod web {
|
||||
bridge.post_ready();
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
Ok(Box::new(Shell(app, inbox, host)))
|
||||
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -24,6 +24,10 @@ winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
# View ▸ Theme ▸ System: the desktop's light/dark choice from the XDG desktop portal
|
||||
# (desktop_theme.rs). Already in Cargo.lock through accesskit; pinned to that version so nothing
|
||||
# is built twice.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
@@ -31,3 +35,16 @@ sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: Windows' Animation effects
|
||||
# setting (system_animations.rs). Already in Cargo.lock through wgpu's DirectX 12 backend.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
|
||||
@@ -1,64 +1,380 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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, Write};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
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,
|
||||
Err(e) => {
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
/// 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,
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<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;
|
||||
}
|
||||
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;
|
||||
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> {
|
||||
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}");
|
||||
sync_channel::<ControlRequest>(0).1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [`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 {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx, limits);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
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| 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);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
|
||||
Ok(false) => 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_line_is_refused_without_reading_the_rest() {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
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();
|
||||
(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}));
|
||||
}
|
||||
});
|
||||
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();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
// Idle connections are accepted in order and held open.
|
||||
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();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! The desktop's own light/dark choice, for View ▸ Theme ▸ System.
|
||||
//!
|
||||
//! winit reports no system theme on Linux and the BSDs (`RawInput::system_theme` stays `None`),
|
||||
//! so the System choice fell back to dark whatever the desktop said. This asks the desktop itself:
|
||||
//! the XDG Desktop Portal's Settings interface, which answers on GNOME, KDE and XFCE alike, over
|
||||
//! X11 and Wayland alike. The answer is handed to egui as the system theme
|
||||
//! (`App::raw_input_hook`), so the app and egui's own widgets follow it as they follow macOS and
|
||||
//! Windows.
|
||||
//!
|
||||
//! Ported from PdfCraft (storytold/pdfcraft#881).
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often the watcher re-reads the desktop's answer. The portal also publishes a change
|
||||
/// signal, but subscribing needs a connection kept alive for the life of the window; one round
|
||||
/// trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Watches the desktop and reports its choice when it changes.
|
||||
#[derive(Default)]
|
||||
pub struct DesktopTheme {
|
||||
/// `None` when no watcher thread could be started (or on a platform where winit answers).
|
||||
changes: Option<Receiver<Option<egui::Theme>>>,
|
||||
/// The desktop's last answer.
|
||||
current: Option<egui::Theme>,
|
||||
}
|
||||
|
||||
impl DesktopTheme {
|
||||
/// Start watching. The first answer is sent straight away; `wake` is called after each
|
||||
/// answer, so an idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_DESKTOP {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-desktop-theme".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), current: None }
|
||||
}
|
||||
|
||||
/// The desktop's choice, `None` when it states none or there is nobody to ask (no session
|
||||
/// bus, no portal). Polled once a frame; a desktop that stops answering keeps the last answer.
|
||||
pub fn current(&mut self) -> Option<egui::Theme> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(theme)) => self.current = Some(theme),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where winit has no answer and the portal does.
|
||||
const ASKS_THE_DESKTOP: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<egui::Theme> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (GNOME and KDE answer it: 0 no preference, 1 dark, 2 light); then
|
||||
// GNOME's own schema, which XFCE's portal proxies without the standard key.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "color-scheme"), ("org.gnome.desktop.interface", "color-scheme")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(scheme) = parse(&answer, 0) {
|
||||
return scheme;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme` answer: `Some(choice)` when it read as an answer (`Some(None)`
|
||||
/// for "no preference"), `None` when it didn't. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<Option<egui::Theme>> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(1) => Some(Some(egui::Theme::Dark)),
|
||||
Value::U32(2) => Some(Some(egui::Theme::Light)),
|
||||
Value::U32(_) => Some(None),
|
||||
Value::Str(s) => Some(match s.as_str() {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn read() -> Option<egui::Theme> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The standard key answers a number, GNOME's schema a string; either may come wrapped in
|
||||
/// variants (the older `Read`). Only "dark" and "light" are a choice.
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_a_choice_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::U32(2), 0), Some(Some(egui::Theme::Light)));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(None));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(None));
|
||||
assert_eq!(parse(&Value::from("prefer-dark"), 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::from("prefer-light"), 0), Some(Some(egui::Theme::Light)));
|
||||
assert_eq!(parse(&Value::from("default"), 0), Some(None));
|
||||
let wrapped = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1)))));
|
||||
assert_eq!(parse(&wrapped, 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), None);
|
||||
let mut deep = Value::U32(1);
|
||||
for _ in 0..10 {
|
||||
deep = Value::Value(Box::new(deep));
|
||||
}
|
||||
assert_eq!(parse(&deep, 0), None, "nesting is bounded");
|
||||
}
|
||||
|
||||
/// A watcher that was never started is inert rather than a panic.
|
||||
#[test]
|
||||
fn a_watcher_without_a_thread_is_inert() {
|
||||
assert_eq!(DesktopTheme::default().current(), None);
|
||||
}
|
||||
|
||||
/// Answers arrive as changes; "no preference" or silence keeps the last real answer.
|
||||
#[test]
|
||||
fn the_last_answer_stands_until_a_new_one() {
|
||||
let (tx, rx) = channel();
|
||||
let mut watcher = DesktopTheme { changes: Some(rx), current: None };
|
||||
assert_eq!(watcher.current(), None);
|
||||
tx.send(Some(egui::Theme::Light)).unwrap();
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Light));
|
||||
tx.send(Some(egui::Theme::Dark)).unwrap();
|
||||
tx.send(None).unwrap();
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
|
||||
drop(tx);
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,14 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod control_server;
|
||||
mod desktop_theme;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -27,6 +30,11 @@ use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
spacemouse: spacemouse::SpaceMouse,
|
||||
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
|
||||
desktop_theme: desktop_theme::DesktopTheme,
|
||||
/// Whether the OS shows animations, for the System choice of View ▸ Accessibility ▸ Animation Effects.
|
||||
system_animations: system_animations::SystemAnimations,
|
||||
workspace: workspace::Workspace,
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -48,6 +56,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System resolves against `raw.system_theme`, which winit leaves empty on Linux
|
||||
/// and the BSDs: the desktop's answer, where it gives one, stands in for it.
|
||||
fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
if desktop.is_some() {
|
||||
raw.system_theme = desktop;
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -59,13 +75,16 @@ impl eframe::App for App {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
}
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
self.cad.logic(ctx);
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
self.persist_workspace(false);
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
follow_desktop(raw, self.desktop_theme.current());
|
||||
self.cad.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
@@ -100,6 +119,11 @@ pub fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
@@ -107,6 +131,7 @@ fn services() -> Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -159,6 +184,9 @@ fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<s
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -184,6 +212,19 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
|
||||
port
|
||||
}
|
||||
|
||||
/// `--control` followed by `value`: a bare `--control` (last argument) keeps the port already
|
||||
/// read from `CADCRAFT_CONTROL_PORT` (`current`) instead of turning the control channel off.
|
||||
fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
|
||||
if value.is_some() {
|
||||
return control_port_from("--control", value);
|
||||
}
|
||||
match current {
|
||||
Some(port) => log::warn!("--control: no port given; using CADCRAFT_CONTROL_PORT ({port})"),
|
||||
None => log::warn!("--control: no port given; the control channel is off"),
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
@@ -195,7 +236,7 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = control_port_from("--control", args.next()),
|
||||
"--control" => control_port = control_flag(control_port, args.next()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("{}", version_string());
|
||||
@@ -232,6 +273,8 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
@@ -240,6 +283,17 @@ fn main() -> eframe::Result {
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
// Command previews (COPY, MOVE...) of large selections are tessellated by egui, whose index
|
||||
// buffer outgrew wgpu's default 256 MiB cap; the validation error is fatal. Ask for what the
|
||||
// adapter actually supports.
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
let base = std::sync::Arc::clone(&create.device_descriptor);
|
||||
create.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut d = base(adapter);
|
||||
d.required_limits.max_buffer_size = d.required_limits.max_buffer_size.max(adapter.limits().max_buffer_size);
|
||||
d
|
||||
});
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
@@ -277,13 +331,20 @@ fn main() -> eframe::Result {
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = config_dir() {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
|
||||
// folder is made so it is there to put them in.
|
||||
let support = dir.join("support");
|
||||
if let Err(e) = std::fs::create_dir_all(&support) {
|
||||
log::warn!("no LISP support folder {}: {e}", support.display());
|
||||
}
|
||||
app.session.set_lisp_support_dir(Some(support));
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync {
|
||||
app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240));
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs);
|
||||
app.set_wgpu(rs, eframe::egui_wgpu::depth_format_from_bits(0, STENCIL_BITS));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -298,8 +359,15 @@ fn main() -> eframe::Result {
|
||||
if app.session.docs.is_empty() {
|
||||
app.session.new_drawing(false);
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let system_animations = system_animations::SystemAnimations::start(move || ctx.request_repaint());
|
||||
Ok(Box::new(App {
|
||||
desktop_theme,
|
||||
system_animations,
|
||||
cad: app,
|
||||
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
|
||||
workspace,
|
||||
workspace_error,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -311,7 +379,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{config_dir_from, control_port_from, filters_for_save};
|
||||
use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -345,6 +413,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_without_a_port_keeps_the_environment_port() {
|
||||
// `CADCRAFT_CONTROL_PORT=47321 cadcraft --control` (#844)
|
||||
assert_eq!(control_flag(Some(47321), None), Some(47321));
|
||||
assert_eq!(control_flag(None, None), None);
|
||||
// A port given on the command line still wins over the variable.
|
||||
assert_eq!(control_flag(Some(47321), Some("7979".into())), Some(7979));
|
||||
assert_eq!(control_flag(Some(47321), Some("abc".into())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_override_variable_wins_and_empty_values_are_unset() {
|
||||
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
|
||||
@@ -377,4 +455,19 @@ mod tests {
|
||||
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
|
||||
);
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
|
||||
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
|
||||
#[test]
|
||||
fn the_desktop_answers_for_the_system_theme() {
|
||||
let mut raw = egui::RawInput::default();
|
||||
follow_desktop(&mut raw, Some(egui::Theme::Light));
|
||||
assert_eq!(raw.system_theme, Some(egui::Theme::Light));
|
||||
let mut raw = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
|
||||
follow_desktop(&mut raw, None);
|
||||
assert_eq!(raw.system_theme, Some(egui::Theme::Dark));
|
||||
let mut raw = egui::RawInput::default();
|
||||
follow_desktop(&mut raw, None);
|
||||
assert_eq!(raw.system_theme, None);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ pub struct NativeMenu {
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
last_refresh: f64,
|
||||
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
|
||||
recent: Vec<String>,
|
||||
counter: usize,
|
||||
}
|
||||
|
||||
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
|
||||
@@ -57,13 +60,31 @@ impl NativeMenu {
|
||||
items.insert("cc-settings".into(), ("options".into(), "Settings...".into(), settings));
|
||||
let _ = menu.append(&app_menu);
|
||||
for (title, entries) in menus::tree(app) {
|
||||
let sub = Submenu::new(i18n::tr(language, &title), true);
|
||||
let sub = Submenu::new(i18n::tr_menu(language, &title), true);
|
||||
build(&sub, &entries, &mut items, &mut submenus, &mut counter, language);
|
||||
let _ = menu.append(&sub);
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 }
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
|
||||
}
|
||||
|
||||
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
|
||||
/// menu is built once, and its items name the files by position.
|
||||
fn refresh_recent(&mut self, app: &CadApp) {
|
||||
if self.recent == app.ui.recent_files {
|
||||
return;
|
||||
}
|
||||
self.recent = app.ui.recent_files.clone();
|
||||
let Some(sub) = self.submenus.iter().find(|(label, _)| label == menus::OPEN_RECENT).map(|(_, s)| s.clone()) else { return };
|
||||
for _ in sub.items() {
|
||||
sub.remove_at(0);
|
||||
}
|
||||
let ids: Vec<String> = self.items.iter().filter(|(_, (cmd, _, _))| cmd.starts_with("ui.recent.")).map(|(id, _)| id.clone()).collect();
|
||||
for id in ids {
|
||||
self.items.remove(&id);
|
||||
}
|
||||
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
@@ -77,6 +98,7 @@ impl NativeMenu {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
self.refresh_recent(app);
|
||||
let language = app.language();
|
||||
if self.language != language {
|
||||
self.language = language;
|
||||
@@ -87,7 +109,7 @@ impl NativeMenu {
|
||||
item.set_text(i18n::tr(language, label));
|
||||
}
|
||||
for (label, submenu) in &self.submenus {
|
||||
submenu.set_text(i18n::tr(language, label));
|
||||
submenu.set_text(i18n::tr_menu(language, label));
|
||||
}
|
||||
}
|
||||
let now = cadcraft_ui_egui::now_ms();
|
||||
@@ -113,7 +135,7 @@ fn build(
|
||||
for e in entries {
|
||||
match e {
|
||||
Entry::Sub { label, children } => {
|
||||
let sub = Submenu::new(i18n::tr(language, label), true);
|
||||
let sub = Submenu::new(i18n::tr_menu(language, label), true);
|
||||
build(&sub, children, items, submenus, counter, language);
|
||||
let _ = parent.append(&sub);
|
||||
submenus.push((label.clone(), sub));
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Native 3Dconnexion input. The reader publishes one latest sample, never an input queue;
|
||||
//! only movement starting/stopping or status/buttons wake egui. Active motion runs at 60 Hz.
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod motion;
|
||||
|
||||
use cadcraft_ui_egui::{CadApp, spacemouse::Status};
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
status: Status,
|
||||
device: Option<String>,
|
||||
detail: Option<String>,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
motion: motion::Motion,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
last_report: Option<Instant>,
|
||||
buttons: u8,
|
||||
recalibrate: bool,
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
last_frame: Instant,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
moving: bool,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
pub fn start(ctx: egui::Context) -> Self {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
let (input, quit, context) = (Arc::clone(&shared), Arc::clone(&stop), ctx.clone());
|
||||
if let Err(error) = std::thread::Builder::new().name("spacemouse-hid".into()).spawn(move || read(input, quit, context)) {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
let _ = ctx;
|
||||
Self {
|
||||
shared,
|
||||
stop,
|
||||
last_frame: Instant::now(),
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
moving: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
let Ok(mut shared) = self.shared.lock() else { return };
|
||||
if std::mem::take(&mut app.spacemouse.calibrate_requested) {
|
||||
shared.recalibrate = true;
|
||||
}
|
||||
let changed = app.spacemouse.status != shared.status || app.spacemouse.device != shared.device || app.spacemouse.detail != shared.detail;
|
||||
app.spacemouse.status = shared.status;
|
||||
app.spacemouse.device.clone_from(&shared.device);
|
||||
app.spacemouse.detail.clone_from(&shared.detail);
|
||||
if changed && !matches!(shared.status, Status::Unavailable | Status::Searching) {
|
||||
app.set_status(cadcraft_ui_egui::i18n::tr(app.language(), shared.status.message()));
|
||||
}
|
||||
let buttons = std::mem::take(&mut shared.buttons);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let motion = if shared.last_report.is_some_and(|at| now.saturating_duration_since(at) < Duration::from_millis(250)) {
|
||||
shared.motion
|
||||
} else {
|
||||
motion::Motion::default()
|
||||
};
|
||||
drop(shared);
|
||||
let allowed = app.spacemouse.settings.enabled
|
||||
&& ctx.input(|i| i.focused)
|
||||
&& !app.ui.start_tab
|
||||
&& app.ui.dialog.is_none()
|
||||
&& app.closing.is_none()
|
||||
&& app.canvas.rect.is_some()
|
||||
&& !egui::Popup::is_any_open(ctx);
|
||||
if allowed && buttons & 1 != 0 {
|
||||
let _ = app.run("zoom", serde_json::json!({"mode": "extents"}));
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
let moving = allowed && motion.active();
|
||||
if moving {
|
||||
if self.moving {
|
||||
let settings = app.spacemouse.settings;
|
||||
let rate = if settings.reverse_zoom { -settings.zoom_speed } else { settings.zoom_speed };
|
||||
if let Err(error) = app.run(
|
||||
"ui.spacemouse.navigate",
|
||||
serde_json::json!({
|
||||
"pan": motion.pan.map(|v| v * settings.pan_speed * elapsed),
|
||||
"zoom": (motion.zoom * rate * elapsed).exp()
|
||||
}),
|
||||
) {
|
||||
app.set_status(error);
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
self.moving = moving;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
let _ = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpaceMouse {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn set_status(shared: &Mutex<Shared>, ctx: &egui::Context, status: Status, device: Option<String>, detail: Option<String>) {
|
||||
let Ok(mut state) = shared.lock() else { return };
|
||||
let changed = state.status != status || state.device != device || state.detail != detail;
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Some(error) = &detail {
|
||||
log::warn!("3D mouse: {error}");
|
||||
}
|
||||
state.status = status;
|
||||
state.device = device;
|
||||
state.detail = detail;
|
||||
state.motion = motion::Motion::default();
|
||||
state.last_report = None;
|
||||
state.buttons = 0;
|
||||
drop(state);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn retry(stop: &AtomicBool) {
|
||||
for _ in 0..40 {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn read(shared: Arc<Mutex<Shared>>, stop: Arc<AtomicBool>, ctx: egui::Context) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let mut api = match hidapi::HidApi::new() {
|
||||
Ok(api) => api,
|
||||
Err(error) => {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
retry(&stop);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
break;
|
||||
}
|
||||
let device =
|
||||
api.device_list().find(|device| matches!(device.vendor_id(), 0x046d | 0x256f) && device.usage_page() == 1 && device.usage() == 8);
|
||||
let Some(info) = device else {
|
||||
set_status(&shared, &ctx, Status::Searching, None, None);
|
||||
retry(&stop);
|
||||
continue;
|
||||
};
|
||||
let name = info.product_string().unwrap_or("3Dconnexion SpaceMouse").to_string();
|
||||
match info.open_device(&api) {
|
||||
Ok(device) => {
|
||||
set_status(&shared, &ctx, Status::Calibrating, Some(name.clone()), None);
|
||||
if let Err(error) = read_device(&device, &shared, &stop, &ctx, &name) {
|
||||
set_status(&shared, &ctx, Status::Disconnected, Some(name), Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
Err(error) => set_status(&shared, &ctx, Status::OpenFailed, Some(name), Some(error.to_string())),
|
||||
}
|
||||
retry(&stop);
|
||||
}
|
||||
retry(&stop);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn read_device(device: &hidapi::HidDevice, shared: &Mutex<Shared>, stop: &AtomicBool, ctx: &egui::Context, name: &str) -> hidapi::HidResult<()> {
|
||||
let mut filter = motion::Filter::default();
|
||||
let mut buffer = [0; 64];
|
||||
let mut buttons = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let recalibrate = if let Ok(mut state) = shared.lock() { std::mem::take(&mut state.recalibrate) } else { return Ok(()) };
|
||||
if recalibrate {
|
||||
filter.recalibrate();
|
||||
set_status(shared, ctx, Status::Calibrating, Some(name.into()), None);
|
||||
}
|
||||
let len = device.read_timeout(&mut buffer, 50)?;
|
||||
let now = Instant::now();
|
||||
let report = buffer.get(..len).and_then(motion::decode);
|
||||
let Ok(mut state) = shared.lock() else { return Ok(()) };
|
||||
let mut wake = false;
|
||||
match report {
|
||||
Some(motion::Report::Buttons(next)) => {
|
||||
let pressed = next & !buttons;
|
||||
buttons = next;
|
||||
state.buttons |= pressed & 1;
|
||||
state.recalibrate |= pressed & 2 != 0;
|
||||
wake = pressed != 0;
|
||||
}
|
||||
Some(motion::Report::Axes { values, mask }) => {
|
||||
let motion = filter.update(values, mask, now);
|
||||
wake = state.motion.active() != motion.active();
|
||||
state.motion = motion;
|
||||
state.last_report = Some(now);
|
||||
if filter.calibrated() && state.status != Status::Connected {
|
||||
state.status = Status::Connected;
|
||||
wake = true;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if state.motion.active() && state.last_report.is_some_and(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
|
||||
state.motion = motion::Motion::default();
|
||||
wake = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
if wake {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Bounded, dependency-free decoding and rest calibration of 3Dconnexion HID input reports.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FULL_SCALE: f64 = 500.0;
|
||||
const DEADZONE: f64 = 0.04;
|
||||
const REST_LIMIT: f64 = 60.0;
|
||||
const REST_RANGE: f64 = 10.0;
|
||||
const CALIBRATION_TIME: Duration = Duration::from_millis(400);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Report {
|
||||
/// Mask 1 = translation, 2 = rotation, 3 = a combined six-axis report.
|
||||
Axes {
|
||||
values: [i16; 6],
|
||||
mask: u8,
|
||||
},
|
||||
Buttons(u8),
|
||||
}
|
||||
|
||||
/// hidapi includes the report ID. A legacy motion report has six payload bytes; combined
|
||||
/// report 1 has twelve. Windows may pad the short button report to the input-report size.
|
||||
pub fn decode(bytes: &[u8]) -> Option<Report> {
|
||||
let (&id, payload) = bytes.split_first()?;
|
||||
if id == 3 {
|
||||
return payload.first().copied().map(Report::Buttons);
|
||||
}
|
||||
let (mask, offset, count) = match id {
|
||||
1 if payload.len() >= 12 => (3, 0, 6),
|
||||
1 if payload.len() >= 6 => (1, 0, 3),
|
||||
2 if payload.len() >= 6 => (2, 3, 3),
|
||||
_ => return None,
|
||||
};
|
||||
let mut values = [0; 6];
|
||||
for (out, pair) in values.iter_mut().skip(offset).take(count).zip(payload.as_chunks::<2>().0.iter()) {
|
||||
*out = i16::from_le_bytes([*pair.first()?, *pair.get(1)?]);
|
||||
}
|
||||
Some(Report::Axes { values, mask })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Motion {
|
||||
pub pan: [f64; 2],
|
||||
pub zoom: f64,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn active(self) -> bool {
|
||||
self.pan.iter().any(|v| *v != 0.0) || self.zoom != 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Rest {
|
||||
since: Option<Instant>,
|
||||
count: u32,
|
||||
sum: [f64; 6],
|
||||
min: [f64; 6],
|
||||
max: [f64; 6],
|
||||
}
|
||||
|
||||
impl Rest {
|
||||
fn observe(&mut self, raw: [f64; 6], now: Instant) -> Option<[f64; 6]> {
|
||||
if raw.iter().any(|v| v.abs() > REST_LIMIT) {
|
||||
*self = Self::default();
|
||||
return None;
|
||||
}
|
||||
if self.since.is_none() || raw.iter().zip(self.min.iter().zip(&self.max)).any(|(v, (min, max))| v.max(*max) - v.min(*min) > REST_RANGE) {
|
||||
*self = Self { since: Some(now), count: 1, sum: raw, min: raw, max: raw };
|
||||
return None;
|
||||
}
|
||||
for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(raw) {
|
||||
*min = min.min(value);
|
||||
*max = max.max(value);
|
||||
}
|
||||
// A fixed bound even if a device sends far more than the expected 125 reports/sec.
|
||||
if self.count < 128 {
|
||||
self.count += 1;
|
||||
for (sum, value) in self.sum.iter_mut().zip(raw) {
|
||||
*sum += value;
|
||||
}
|
||||
}
|
||||
if self.count >= 16 && self.since.is_some_and(|since| now.saturating_duration_since(since) >= CALIBRATION_TIME) {
|
||||
Some(self.sum.map(|sum| sum / f64::from(self.count)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
raw: [f64; 6],
|
||||
bias: Option<[f64; 6]>,
|
||||
rest: Rest,
|
||||
parts: u8,
|
||||
seen: [Option<Instant>; 2],
|
||||
idle_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn calibrated(&self) -> bool {
|
||||
self.bias.is_some()
|
||||
}
|
||||
|
||||
pub fn recalibrate(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
|
||||
for (index, seen) in self.seen.iter_mut().enumerate() {
|
||||
if mask & (1 << index) != 0 {
|
||||
*seen = Some(now);
|
||||
}
|
||||
}
|
||||
for (index, (raw, value)) in self.raw.iter_mut().zip(values).enumerate() {
|
||||
if mask & if index < 3 { 1 } else { 2 } != 0 {
|
||||
*raw = f64::from(value);
|
||||
}
|
||||
}
|
||||
self.parts |= mask & 3;
|
||||
let complete = self.parts == 3;
|
||||
if complete {
|
||||
self.parts = 0;
|
||||
}
|
||||
if self.bias.is_none() && complete {
|
||||
self.bias = self.rest.observe(self.raw, now);
|
||||
}
|
||||
let Some(mut bias) = self.bias else { return Motion::default() };
|
||||
// Follow small resting drift only after two seconds entirely inside the deadzone.
|
||||
if complete {
|
||||
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (bias, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*bias += (raw - *bias) * 0.01;
|
||||
}
|
||||
self.bias = Some(bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
let mut axes = [0.0; 6];
|
||||
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
|
||||
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
|
||||
*axis = if value.abs() <= DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) };
|
||||
}
|
||||
// A missing rotation report must not keep zooming while translations still arrive.
|
||||
for (group, seen) in axes.as_chunks_mut::<3>().0.iter_mut().zip(self.seen) {
|
||||
if seen.is_none_or(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
|
||||
group.fill(0.0);
|
||||
}
|
||||
}
|
||||
let [tx, ty, tz, _rx, _ry, rz] = axes;
|
||||
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Whether the operating system shows animations, for View ▸ Accessibility ▸ Animation Effects ▸
|
||||
//! Use System Setting.
|
||||
//!
|
||||
//! egui animates whatever the OS says (windows and menus fade in, wheel scrolling glides), and
|
||||
//! winit reports no such setting. This asks the OS itself:
|
||||
//!
|
||||
//! - Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects.
|
||||
//! - macOS: System Settings ▸ Accessibility ▸ Reduce motion.
|
||||
//! - Linux and the BSDs: the XDG Desktop Portal's Settings interface, as for the theme
|
||||
//! (`desktop_theme.rs`): its `reduced-motion`, or GNOME's own `enable-animations`.
|
||||
//!
|
||||
//! The answer goes to `CadApp::system_animations` once a frame.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the OS is asked again, so a change to its setting is followed while CADCraft runs.
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Watches the OS setting.
|
||||
#[derive(Default)]
|
||||
pub struct SystemAnimations {
|
||||
/// The watcher thread's answers, where asking can block (the portal); `None` elsewhere, or
|
||||
/// when no thread could be started.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
/// The OS's last answer.
|
||||
current: Option<bool>,
|
||||
/// When the OS was last asked from the UI thread, where asking is one quick call.
|
||||
asked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SystemAnimations {
|
||||
/// Start watching. Where a thread does the asking, `wake` is called after each answer, so an
|
||||
/// idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_PORTAL {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-system-animations".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), ..Self::default() }
|
||||
}
|
||||
|
||||
/// Whether the OS shows animations; `None` when it states nothing or there is nobody to ask.
|
||||
/// Polled once a frame; an OS that stops answering keeps its last answer.
|
||||
pub fn current(&mut self) -> Option<bool> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(animate)) => self.current = Some(animate),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
} else if !ASKS_THE_PORTAL && self.asked.is_none_or(|at| at.elapsed() >= POLL) {
|
||||
self.asked = Some(Instant::now());
|
||||
if let Some(animate) = read() {
|
||||
self.current = Some(animate);
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the answer comes over D-Bus, which can block: a thread asks. Elsewhere the UI thread asks.
|
||||
const ASKS_THE_PORTAL: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask Windows once: `UISettings::AnimationsEnabled`, the setting behind
|
||||
/// `SPI_GETCLIENTAREAANIMATION`.
|
||||
#[cfg(windows)]
|
||||
fn read() -> Option<bool> {
|
||||
windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok()
|
||||
}
|
||||
|
||||
/// Ask macOS once. Called on the main thread (`SystemAnimations::current`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read() -> Option<bool> {
|
||||
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<bool> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (0 no preference, 1 reduced motion); then GNOME's own switch, which
|
||||
// its portal answered before the standard key existed.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "reduced-motion"), ("org.gnome.desktop.interface", "enable-animations")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(animate) = parse(&answer, 0) {
|
||||
return Some(animate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's answer as "show animations": `reduced-motion` is a number (1 asks for less
|
||||
/// motion; 0 and values from a later version state no preference), `enable-animations` a
|
||||
/// boolean. `None` when it reads as neither. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<bool> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(reduced) => Some(*reduced != 1),
|
||||
Value::Bool(enabled) => Some(*enabled),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
// macOS is asked on the main thread only, so there is nothing to test off it.
|
||||
#[cfg(all(test, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Windows is asked on the first frame, then not again until the poll interval has passed.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn the_os_is_asked_on_the_first_frame_and_its_answer_kept() {
|
||||
let mut watch = SystemAnimations::start(|| {});
|
||||
assert!(watch.changes.is_none(), "no thread: asking is one quick call");
|
||||
let first = watch.current();
|
||||
assert_eq!(first, read());
|
||||
let asked = watch.asked;
|
||||
assert!(asked.is_some());
|
||||
assert_eq!(watch.current(), first);
|
||||
assert_eq!(watch.asked, asked, "not asked again within the poll interval");
|
||||
}
|
||||
|
||||
/// The standard key answers a number, GNOME's switch a boolean; either may come wrapped in
|
||||
/// variants (the older `Read`).
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_show_animations_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(false));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(true));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(true), "unknown values are no preference");
|
||||
assert_eq!(parse(&Value::Bool(false), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), Some(true));
|
||||
assert_eq!(parse(&Value::Value(Box::new(Value::U32(1))), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Str("off".into()), 0), None);
|
||||
// A hostile answer nested without end is given up on.
|
||||
let mut nested = Value::U32(1);
|
||||
for _ in 0..16 {
|
||||
nested = Value::Value(Box::new(nested));
|
||||
}
|
||||
assert_eq!(parse(&nested, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rangoDJ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Tru10]
|
||||
display_name = "by Truitt"
|
||||
sync_github_name = false
|
||||
@@ -37,3 +45,31 @@ sync_github_name = true
|
||||
[people.WladmirJunior]
|
||||
real_name = "Wladmir Júnior"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GJoe2]
|
||||
sync_github_name = true
|
||||
|
||||
[people.holmbom]
|
||||
real_name = "Erik Holmbom"
|
||||
display_name = "Erik Holmbom"
|
||||
sync_github_name = false
|
||||
|
||||
[people.cyfung1031]
|
||||
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")]
|
||||
|
||||
@@ -500,11 +500,14 @@ impl Model {
|
||||
}
|
||||
(EntityKind::Arc(a), Shape::Arc) => {
|
||||
if let [cx, cy, r, s0, s1] = new {
|
||||
// A negative radius puts every point opposite its angle: turning both
|
||||
// ends half a turn keeps the same points with a positive radius.
|
||||
let turn = if *r < 0.0 { std::f64::consts::PI } else { 0.0 };
|
||||
a.center.x = *cx;
|
||||
a.center.y = *cy;
|
||||
a.radius = r.abs().max(1e-12);
|
||||
a.start = cadcraft_geom::norm_angle(*s0);
|
||||
a.end = cadcraft_geom::norm_angle(*s1);
|
||||
a.start = cadcraft_geom::norm_angle(*s0 + turn);
|
||||
a.end = cadcraft_geom::norm_angle(*s1 + turn);
|
||||
}
|
||||
}
|
||||
(EntityKind::LwPolyline(p), Shape::Poly { n, .. }) if p.vertices.len() == n => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -463,3 +463,29 @@ fn set_parameter_rejects_out_of_domain_dimension() {
|
||||
set_parameter(&mut d, "rad1", "2", &opts).unwrap();
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
/// The solver may meet a coincidence with a negative arc radius; the stored arc must still put
|
||||
/// the referenced point where the solver did (issue #703), through `add` and through `solve`.
|
||||
#[test]
|
||||
fn arc_solved_with_a_negative_radius_keeps_its_points() {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
// (arc centre, radius, start, end, point): a semicircle with the point behind it, and the
|
||||
// asymmetric case from the issue.
|
||||
let cases = [((0.0, 0.0), 1.0, -FRAC_PI_2, FRAC_PI_2, (-5.0, 0.0)), ((6.694, -0.686), 0.62075, 3.4824, 1.1766, (-7.562, 4.946))];
|
||||
for (c, r, a0, a1, p) in cases {
|
||||
for through_solve in [false, true] {
|
||||
let mut d = Drawing::default();
|
||||
let a = arc(&mut d, c, r, a0, a1);
|
||||
let q = add_e(&mut d, EntityKind::Point(DPoint { p: v(p.0, p.1), angle: 0.0 }));
|
||||
let k = con(ConstraintKind::Coincident, &[s(a, Sub::Mid), w(q)]);
|
||||
if through_solve {
|
||||
d.constraints.push(Constraint { id: 1, ..k });
|
||||
solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
} else {
|
||||
ok(&mut d, k);
|
||||
}
|
||||
assert!(all_satisfied(&d), "{c:?} solve {through_solve}: {:?}", residuals(&d));
|
||||
assert!(circ(&d, a).1 > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,9 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
EntityKind::MText(t) => {
|
||||
t.height *= f;
|
||||
t.width *= f;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(f);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
dm.overrides.insert("scale".into(), serde_json::json!(f));
|
||||
@@ -172,6 +175,9 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
t.insert = land + (t.insert - land) * f;
|
||||
t.height *= f;
|
||||
t.width *= f;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(f);
|
||||
}
|
||||
}
|
||||
if let Some(ins) = &mut m.block {
|
||||
ins.insert = land + (ins.insert - land) * f;
|
||||
@@ -188,6 +194,11 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
a.text.insert = o + (a.text.insert - o) * f;
|
||||
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
|
||||
a.text.height *= f;
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
|
||||
ml.mtext.height *= f;
|
||||
ml.mtext.width *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Sub {
|
||||
"mid" | "midpoint" | "m" => Sub::Mid,
|
||||
"center" | "centre" | "c" => Sub::Center,
|
||||
_ => {
|
||||
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("v", true)] {
|
||||
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("s", false), ("v", true)] {
|
||||
if let Some(n) = l.strip_prefix(p) {
|
||||
let i: u32 = n.trim().parse().ok()?;
|
||||
return Some(if vertex { Sub::Vertex(i) } else { Sub::Segment(i) });
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Dynamic blocks made in another program, read from the data CADCraft keeps for them.
|
||||
//!
|
||||
//! A dynamic block's behaviour lives in objects CADCraft does not write itself and keeps as
|
||||
//! [`Foreign`](crate::foreign::Foreign) data: the block's BLOCK_RECORD has an extension dictionary
|
||||
//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the
|
||||
//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and
|
||||
//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…).
|
||||
//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing
|
||||
//! saved again still carries them exactly as read.
|
||||
//!
|
||||
//! Every count and index here comes from the file: they are capped by the data actually present
|
||||
//! and unknown or damaged objects are skipped, never trusted.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::foreign::ForeignObject;
|
||||
use crate::{Drawing, Handle, RawTag};
|
||||
|
||||
mod apply;
|
||||
mod grips;
|
||||
mod reference;
|
||||
pub use apply::{drag_grip, set_property};
|
||||
pub use grips::{DynGrip, GripPart, GripShape, grips};
|
||||
pub use reference::{CustomProperty, Reference, Value, custom_properties, reference};
|
||||
|
||||
/// Most objects one evaluation graph is read with.
|
||||
const MAX_NODES: usize = 10_000;
|
||||
|
||||
/// Handles as hex strings in JSON, as commands report them.
|
||||
fn hex<S: serde::Serializer>(h: &Handle, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&h.hex())
|
||||
}
|
||||
fn hexes<S: serde::Serializer>(hs: &[Handle], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_seq(hs.iter().map(|h| h.hex()))
|
||||
}
|
||||
fn hex_vertices<S: serde::Serializer>(v: &[(Handle, Vec<i32>)], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_seq(v.iter().map(|(h, i)| (h.hex(), i)))
|
||||
}
|
||||
|
||||
/// A dynamic block's parameters and actions.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicBlock {
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub actions: Vec<Action>,
|
||||
/// Graph members of types not modelled here (grips, constraints…), by DXF type.
|
||||
pub other: Vec<String>,
|
||||
/// The grip objects by graph id (parameters name their grips by id; visibility states list
|
||||
/// the grips shown in them by object).
|
||||
#[serde(skip)]
|
||||
pub grip_objects: Vec<(i32, Handle)>,
|
||||
}
|
||||
|
||||
/// One parameter: a property a reference to the block can change.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Parameter {
|
||||
/// Its id in the evaluation graph (actions and lookups refer to parameters by it).
|
||||
pub node: i32,
|
||||
#[serde(serialize_with = "hex")]
|
||||
pub handle: Handle,
|
||||
/// The name the block editor shows ("Linear", "Visibility State").
|
||||
pub element: String,
|
||||
/// The parameter's properties appear in a reference's Properties palette.
|
||||
pub show_properties: bool,
|
||||
pub kind: ParameterKind,
|
||||
/// Graph ids of its grips: a two-point parameter's base and end grips (0 for none), a
|
||||
/// one-point parameter's grip. A reference's history records grip drags by these.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub grips: Vec<i32>,
|
||||
}
|
||||
|
||||
/// What a parameter is, with its definition geometry in block coordinates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ParameterKind {
|
||||
/// A distance between two points (Distance1).
|
||||
Linear {
|
||||
base: Vec3,
|
||||
end: Vec3,
|
||||
label: String,
|
||||
description: String,
|
||||
values: ValueSet,
|
||||
/// The value changes the end point (`false`) or both points from the midpoint (`true`).
|
||||
from_midpoint: bool,
|
||||
},
|
||||
/// A distance and an angle from a base point.
|
||||
Polar { base: Vec3, end: Vec3 },
|
||||
/// Horizontal and vertical distances from a base point.
|
||||
Xy { base: Vec3, end: Vec3 },
|
||||
/// An angle about a base point.
|
||||
Rotation { base: Vec3, end: Vec3 },
|
||||
/// Aligns the reference with nearby geometry.
|
||||
Alignment { base: Vec3, end: Vec3 },
|
||||
/// Mirrors the objects about a reflection line.
|
||||
Flip { base: Vec3, end: Vec3 },
|
||||
/// A position (Position1).
|
||||
Point { at: Vec3, label: String, description: String },
|
||||
/// Named sets of visible objects.
|
||||
Visibility {
|
||||
at: Vec3,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(serialize_with = "hexes")]
|
||||
controlled: Vec<Handle>,
|
||||
states: Vec<VisibilityState>,
|
||||
},
|
||||
/// A named value chosen from a lookup table.
|
||||
Lookup { at: Vec3, label: String, description: String },
|
||||
/// Moves the block's base point.
|
||||
BasePoint { at: Vec3 },
|
||||
/// A parameter type not modelled yet.
|
||||
Other { dxf_type: String },
|
||||
}
|
||||
|
||||
impl ParameterKind {
|
||||
/// The property name a reference shows ("Distance1", "Visibility1"), when it has one.
|
||||
pub fn label(&self) -> Option<&str> {
|
||||
match self {
|
||||
ParameterKind::Linear { label, .. }
|
||||
| ParameterKind::Point { label, .. }
|
||||
| ParameterKind::Visibility { label, .. }
|
||||
| ParameterKind::Lookup { label, .. } => Some(label),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One visibility state: its name and the objects shown in it (the parameter's other controlled
|
||||
/// objects are hidden).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VisibilityState {
|
||||
pub name: String,
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub visible: Vec<Handle>,
|
||||
/// The parameters and actions active in this state (their objects).
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub active: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// The values a distance or angle may take.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValueSet {
|
||||
/// Which of the limits below apply (see the methods).
|
||||
pub flags: i32,
|
||||
pub minimum: f64,
|
||||
pub maximum: f64,
|
||||
pub increment: f64,
|
||||
/// The allowed values, for a list.
|
||||
pub list: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ValueSet {
|
||||
pub fn has_minimum(&self) -> bool {
|
||||
self.flags & 1 != 0
|
||||
}
|
||||
pub fn has_maximum(&self) -> bool {
|
||||
self.flags & 2 != 0
|
||||
}
|
||||
pub fn has_increment(&self) -> bool {
|
||||
self.flags & 4 != 0
|
||||
}
|
||||
pub fn is_list(&self) -> bool {
|
||||
self.flags & 8 != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One action: what changing parameters does to the block's objects.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Action {
|
||||
pub node: i32,
|
||||
#[serde(serialize_with = "hex")]
|
||||
pub handle: Handle,
|
||||
/// Its name in the block editor ("Stretch", "Lookup1").
|
||||
pub name: String,
|
||||
/// Where the block editor shows it.
|
||||
pub at: Vec3,
|
||||
/// The objects it acts on.
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub selection: Vec<Handle>,
|
||||
/// Graph ids of the parameters it follows, with the parameter value each connection reads
|
||||
/// ("EndXDelta").
|
||||
pub connections: Vec<(i32, String)>,
|
||||
pub kind: ActionKind,
|
||||
}
|
||||
|
||||
/// What an action does.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ActionKind {
|
||||
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
|
||||
Stretch {
|
||||
frame: Vec<Vec2>,
|
||||
#[serde(serialize_with = "hex_vertices")]
|
||||
vertices: Vec<(Handle, Vec<i32>)>,
|
||||
multiplier: f64,
|
||||
angle_offset: f64,
|
||||
},
|
||||
/// A table: each row sets the listed parameter values together.
|
||||
Lookup {
|
||||
columns: Vec<LookupColumn>,
|
||||
rows: Vec<Vec<String>>,
|
||||
},
|
||||
/// Moves the objects by the change of the parameter values it follows.
|
||||
Move {
|
||||
multiplier: f64,
|
||||
angle_offset: f64,
|
||||
},
|
||||
Scale,
|
||||
Rotate,
|
||||
Flip,
|
||||
Array,
|
||||
PolarStretch,
|
||||
/// An action type not modelled yet.
|
||||
Other {
|
||||
dxf_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A lookup table column: the parameter value it reads or sets.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LookupColumn {
|
||||
/// The parameter's graph id.
|
||||
pub node: i32,
|
||||
/// The value it stands for ("VisibilityState", "lookupString").
|
||||
pub property: String,
|
||||
/// A lookup property (shown on the reference) rather than an input.
|
||||
pub lookup: bool,
|
||||
/// What the reference shows when no row matches ("Custom").
|
||||
pub unmatched: String,
|
||||
}
|
||||
|
||||
/// The groups of an object split at its subclass markers (100): `(marker, groups after it)`.
|
||||
fn sections(tags: &[RawTag]) -> Vec<(&str, &[RawTag])> {
|
||||
let mut out: Vec<(&str, &[RawTag])> = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut name = "";
|
||||
for (i, t) in tags.iter().enumerate() {
|
||||
if t.code == 100 {
|
||||
out.push((name, tags.get(start..i).unwrap_or_default()));
|
||||
name = t.value.as_str();
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push((name, tags.get(start..).unwrap_or_default()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The groups after subclass marker `marker`, empty when the object has none.
|
||||
fn section<'a>(secs: &[(&str, &'a [RawTag])], marker: &str) -> &'a [RawTag] {
|
||||
secs.iter().find(|(n, _)| n.eq_ignore_ascii_case(marker)).map_or(&[], |(_, t)| t)
|
||||
}
|
||||
|
||||
/// Reads groups in order, as an object writes them.
|
||||
struct Cursor<'a> {
|
||||
tags: &'a [RawTag],
|
||||
at: usize,
|
||||
/// Groups scanned so far. A missing group neither moves the cursor nor ends the loops that
|
||||
/// ask for it, so a hostile record could make nested counts rescan it again and again; past
|
||||
/// `budget` every lookup fails at once and every count is zero.
|
||||
work: usize,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn new(tags: &'a [RawTag]) -> Self {
|
||||
Cursor { tags, at: 0, work: 0, budget: tags.len().saturating_mul(16).saturating_add(1024) }
|
||||
}
|
||||
/// The next group with `code` from here on (the cursor moves past it).
|
||||
fn next(&mut self, code: i32) -> Option<&'a str> {
|
||||
if self.work >= self.budget {
|
||||
return None;
|
||||
}
|
||||
let rest = self.tags.get(self.at..)?;
|
||||
let Some(rel) = rest.iter().position(|t| t.code == code) else {
|
||||
self.work = self.work.saturating_add(rest.len().max(1));
|
||||
return None;
|
||||
};
|
||||
self.work = self.work.saturating_add(rel + 1);
|
||||
self.at += rel + 1;
|
||||
self.tags.get(self.at - 1).map(|t| t.value.as_str())
|
||||
}
|
||||
fn string(&mut self, code: i32) -> String {
|
||||
self.next(code).unwrap_or_default().to_string()
|
||||
}
|
||||
fn int(&mut self, code: i32) -> i32 {
|
||||
self.next(code).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
|
||||
}
|
||||
fn real(&mut self, code: i32) -> f64 {
|
||||
self.next(code).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0)
|
||||
}
|
||||
/// A count read from the file, capped by the groups left (each item needs at least one).
|
||||
fn count(&mut self, code: i32) -> usize {
|
||||
let n = usize::try_from(self.int(code)).unwrap_or(0).min(self.tags.len().saturating_sub(self.at));
|
||||
if self.work >= self.budget { 0 } else { n }
|
||||
}
|
||||
fn handle(&mut self, code: i32) -> Option<Handle> {
|
||||
self.next(code).and_then(Handle::parse_hex)
|
||||
}
|
||||
fn handles(&mut self, code: i32, n: usize) -> Vec<Handle> {
|
||||
(0..n).filter_map(|_| self.handle(code)).collect()
|
||||
}
|
||||
/// A point written with codes `x`, `x + 10`, `x + 20`.
|
||||
fn point(&mut self, x: i32) -> Vec3 {
|
||||
let px = self.real(x);
|
||||
let py = self.real(x + 10);
|
||||
let at = self.at;
|
||||
// Z is optional: only take it when it is the very next group.
|
||||
let pz = match self.tags.get(at) {
|
||||
Some(t) if t.code == x + 20 => self.real(x + 20),
|
||||
_ => 0.0,
|
||||
};
|
||||
Vec3::new(px, py, pz)
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
let dict = objects.get(&xdict)?;
|
||||
let mut graph = None;
|
||||
let mut tags = dict.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK") {
|
||||
graph = tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
/// block's own drawing), or `None` when it has none.
|
||||
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
let mut out = DynamicBlock::default();
|
||||
for o in graph_members(&objects, xdict)? {
|
||||
let secs = sections(&o.tags);
|
||||
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
|
||||
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
|
||||
let kind = o.kind.to_ascii_uppercase();
|
||||
if kind.ends_with("PARAMETER") {
|
||||
let show_properties = Cursor::new(section(&secs, "AcDbBlockParameter")).int(280) != 0;
|
||||
let grips = grip_ids(&secs);
|
||||
out.parameters.push(Parameter { node, handle: o.handle, element, show_properties, kind: parameter(&kind, &secs), grips });
|
||||
} else if kind.ends_with("ACTION") {
|
||||
out.actions.push(action(o.handle, node, element, &kind, &secs));
|
||||
} else {
|
||||
if kind.ends_with("GRIP") {
|
||||
out.grip_objects.push((node, o.handle));
|
||||
}
|
||||
out.other.push(o.kind.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A parameter's grip ids: after a two-point parameter's points, the grip count (170) and the
|
||||
/// base and end grips (91); after a one-point parameter's point, its grip (93).
|
||||
fn grip_ids(secs: &[(&str, &[RawTag])]) -> Vec<i32> {
|
||||
let two = section(secs, "AcDbBlock2PtParameter");
|
||||
if !two.is_empty() {
|
||||
let mut c = Cursor::new(two);
|
||||
c.point(1010);
|
||||
c.point(1011);
|
||||
let n = c.count(170).min(2);
|
||||
return (0..n).map(|_| c.int(91)).collect();
|
||||
}
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlock1PtParameter"));
|
||||
c.point(1010);
|
||||
c.next(93).and_then(|v| v.trim().parse().ok()).into_iter().collect()
|
||||
}
|
||||
|
||||
fn parameter(kind: &str, secs: &[(&str, &[RawTag])]) -> ParameterKind {
|
||||
let two = || {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlock2PtParameter"));
|
||||
(c.point(1010), c.point(1011))
|
||||
};
|
||||
let one = || Cursor::new(section(secs, "AcDbBlock1PtParameter")).point(1010);
|
||||
match kind {
|
||||
"BLOCKLINEARPARAMETER" => {
|
||||
let (base, end) = two();
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockLinearParameter"));
|
||||
let label = c.string(305);
|
||||
let description = c.string(306);
|
||||
let from_midpoint = Cursor::new(section(secs, "AcDbBlock2PtParameter")).int(177) == 1;
|
||||
ParameterKind::Linear { base, end, label, description, values: value_set(&mut c), from_midpoint }
|
||||
}
|
||||
"BLOCKPOINTPARAMETER" => {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockPointParameter"));
|
||||
ParameterKind::Point { at: one(), label: c.string(303), description: c.string(304) }
|
||||
}
|
||||
"BLOCKVISIBILITYPARAMETER" => visibility(one(), section(secs, "AcDbBlockVisibilityParameter")),
|
||||
"BLOCKLOOKUPPARAMETER" => {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockLookupParameter"));
|
||||
ParameterKind::Lookup { at: one(), label: c.string(303), description: c.string(304) }
|
||||
}
|
||||
"BLOCKBASEPOINTPARAMETER" => ParameterKind::BasePoint { at: one() },
|
||||
"BLOCKPOLARPARAMETER" | "BLOCKXYPARAMETER" | "BLOCKROTATIONPARAMETER" | "BLOCKALIGNMENTPARAMETER" | "BLOCKFLIPPARAMETER" => {
|
||||
let (base, end) = two();
|
||||
match kind {
|
||||
"BLOCKPOLARPARAMETER" => ParameterKind::Polar { base, end },
|
||||
"BLOCKXYPARAMETER" => ParameterKind::Xy { base, end },
|
||||
"BLOCKROTATIONPARAMETER" => ParameterKind::Rotation { base, end },
|
||||
"BLOCKALIGNMENTPARAMETER" => ParameterKind::Alignment { base, end },
|
||||
_ => ParameterKind::Flip { base, end },
|
||||
}
|
||||
}
|
||||
other => ParameterKind::Other { dxf_type: other.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
/// A value set after a parameter's name and description: description (307), flags (96),
|
||||
/// minimum, maximum and increment (141–143), then the listed values (175 count, 144 each).
|
||||
fn value_set(c: &mut Cursor) -> ValueSet {
|
||||
c.next(307);
|
||||
let flags = c.int(96);
|
||||
let (minimum, maximum, increment) = (c.real(141), c.real(142), c.real(143));
|
||||
let n = c.count(175);
|
||||
let list = (0..n).filter_map(|_| c.next(144).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
||||
ValueSet { flags, minimum, maximum, increment, list }
|
||||
}
|
||||
|
||||
/// A visibility parameter: its name and description (301, 302), the objects it controls (93
|
||||
/// count, 331 each), then each state (92 count): name (303), visible objects (94, 332) and the
|
||||
/// parameters and actions active in it (95, 333).
|
||||
fn visibility(at: Vec3, tags: &[RawTag]) -> ParameterKind {
|
||||
let mut c = Cursor::new(tags);
|
||||
let label = c.string(301);
|
||||
let description = c.string(302);
|
||||
let n = c.count(93);
|
||||
let controlled = c.handles(331, n);
|
||||
let states = (0..c.count(92))
|
||||
.map(|_| {
|
||||
let name = c.string(303);
|
||||
let n = c.count(94);
|
||||
let visible = c.handles(332, n);
|
||||
let n = c.count(95);
|
||||
VisibilityState { name, visible, active: c.handles(333, n) }
|
||||
})
|
||||
.collect();
|
||||
ParameterKind::Visibility { at, label, description, controlled, states }
|
||||
}
|
||||
|
||||
fn action(handle: Handle, node: i32, name: String, kind: &str, secs: &[(&str, &[RawTag])]) -> Action {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockAction"));
|
||||
c.next(70);
|
||||
let n = c.count(71);
|
||||
let selection = c.handles(330, n);
|
||||
let at = c.point(1010);
|
||||
let (connections, kind) = match kind {
|
||||
"BLOCKSTRETCHACTION" => stretch(section(secs, "AcDbBlockStretchAction")),
|
||||
"BLOCKLOOKUPACTION" => (Vec::new(), lookup(section(secs, "AcDbBlockLookupAction"))),
|
||||
"BLOCKMOVEACTION" => {
|
||||
let tags = section(secs, "AcDbBlockMoveAction");
|
||||
let mut c = Cursor::new(tags);
|
||||
let (multiplier, angle_offset) = (c.real(140), c.real(141));
|
||||
(two_connections(tags), ActionKind::Move { multiplier, angle_offset })
|
||||
}
|
||||
"BLOCKSCALEACTION" => (Vec::new(), ActionKind::Scale),
|
||||
"BLOCKROTATEACTION" => (Vec::new(), ActionKind::Rotate),
|
||||
"BLOCKFLIPACTION" => (Vec::new(), ActionKind::Flip),
|
||||
"BLOCKARRAYACTION" => (Vec::new(), ActionKind::Array),
|
||||
"BLOCKPOLARSTRETCHACTION" => (Vec::new(), ActionKind::PolarStretch),
|
||||
other => (Vec::new(), ActionKind::Other { dxf_type: other.to_string() }),
|
||||
};
|
||||
Action { node, handle, name, at, selection, connections, kind }
|
||||
}
|
||||
|
||||
/// Two connections: parameter ids (92, 93) and the values they read (301, 302). Writers order
|
||||
/// these groups differently (92 93 301 302, or 92 301 93 302), so each is read on its own.
|
||||
fn two_connections(tags: &[RawTag]) -> Vec<(i32, String)> {
|
||||
let first = |code: i32| Cursor::new(tags).next(code).unwrap_or_default().to_string();
|
||||
let int = |code: i32| first(code).trim().parse().unwrap_or(0);
|
||||
vec![(int(92), first(301)), (int(93), first(302))]
|
||||
}
|
||||
|
||||
/// A stretch action: its two connections, the frame (72 count, 1011/1021 points), the objects
|
||||
/// whose vertices it moves (73 count; each a 331 handle, a 74 count and 94 vertex indices), then
|
||||
/// the distance multiplier (140) and angle offset (141).
|
||||
fn stretch(tags: &[RawTag]) -> (Vec<(i32, String)>, ActionKind) {
|
||||
let connections = two_connections(tags);
|
||||
let mut c = Cursor::new(tags);
|
||||
let n = c.count(72);
|
||||
let frame = (0..n)
|
||||
.map(|_| {
|
||||
let x = c.real(1011);
|
||||
Vec2::new(x, c.real(1021))
|
||||
})
|
||||
.collect();
|
||||
let objects = c.count(73);
|
||||
let vertices = (0..objects)
|
||||
.filter_map(|_| {
|
||||
let h = c.handle(331)?;
|
||||
let n = c.count(74);
|
||||
Some((h, (0..n).map(|_| c.int(94)).collect()))
|
||||
})
|
||||
.collect();
|
||||
c.next(75);
|
||||
let (multiplier, angle_offset) = (c.real(140), c.real(141));
|
||||
(connections, ActionKind::Stretch { frame, vertices, multiplier, angle_offset })
|
||||
}
|
||||
|
||||
/// A lookup table: rows (92) and columns (93), the cells row by row (302, after a 301 header),
|
||||
/// then each column: unmatched name (303 then 305), parameter id (94), lookup flag (282) and the
|
||||
/// parameter value it stands for (304).
|
||||
fn lookup(tags: &[RawTag]) -> ActionKind {
|
||||
let mut c = Cursor::new(tags);
|
||||
let rows = c.count(92);
|
||||
let cols = c.count(93);
|
||||
c.next(301);
|
||||
let cells = rows.saturating_mul(cols).min(tags.len());
|
||||
let flat: Vec<String> = (0..cells).map(|_| c.string(302)).collect();
|
||||
let rows = flat.chunks(cols.max(1)).map(<[String]>::to_vec).collect();
|
||||
let columns = (0..cols)
|
||||
.map(|_| {
|
||||
c.next(303);
|
||||
let node = c.int(94);
|
||||
let lookup = c.int(282) != 0;
|
||||
let unmatched = c.string(305);
|
||||
let property = c.string(304);
|
||||
LookupColumn { node, property, lookup, unmatched }
|
||||
})
|
||||
.collect();
|
||||
ActionKind::Lookup { columns, rows }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.to_string() }
|
||||
}
|
||||
|
||||
/// Counts that promise groups the record doesn't have: each nested loop would rescan the
|
||||
/// record for them. The work budget keeps this linear in the record's size.
|
||||
#[test]
|
||||
fn hostile_counts_with_missing_groups_stay_linear() {
|
||||
let n = 20_000;
|
||||
let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())];
|
||||
tags.extend((0..n).map(|_| tag(94, &n.to_string())));
|
||||
let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { panic!("a visibility parameter") };
|
||||
assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty()));
|
||||
|
||||
let mut tags = vec![tag(73, &n.to_string())];
|
||||
tags.extend((0..n).flat_map(|_| [tag(331, "1F"), tag(74, &n.to_string())]));
|
||||
let (_, ActionKind::Stretch { vertices, .. }) = stretch(&tags) else { panic!("a stretch action") };
|
||||
let ints: usize = vertices.iter().map(|(_, v)| v.len()).sum();
|
||||
assert!(ints <= 16 * tags.len() + 1024, "{ints}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
//! Changing a dynamic block reference's custom properties.
|
||||
//!
|
||||
//! A changed reference shows an anonymous block (`*U12`) made from its current one with the
|
||||
//! change applied, and says so the way AutoCAD's own references do, so AutoCAD still reads it as
|
||||
//! a reference to the dynamic block: the anonymous block's BLOCK_RECORD carries
|
||||
//! `AcDbBlockRepBTag` xdata naming the dynamic block's record, each copied object keeps the
|
||||
//! `AcDbBlockRepETag` xdata that pairs it with its object in the dynamic block, and the reference's
|
||||
//! `AcDbBlockRepresentation` data holds its values (created when it had none) and its history
|
||||
//! says what was done to it (AutoCAD replays the history when the dynamic block is redefined).
|
||||
//!
|
||||
//! What can change so far: a visibility state, a lookup value, a distance and a position, with
|
||||
//! the stretch and move actions they drive and the lookups that follow. The other parameters'
|
||||
//! actions come later.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
|
||||
use super::{ActionKind, DynamicBlock, ParameterKind, Value, dynamic_block, reference};
|
||||
use crate::foreign::{ForeignExtra, ForeignObject};
|
||||
use crate::{Block, Drawing, Entity, EntityKind, Handle, RawTag};
|
||||
|
||||
/// Xdata application naming a copied object's object in the dynamic block (its element tag).
|
||||
const ETAG: &str = "AcDbBlockRepETag";
|
||||
/// Xdata application naming an anonymous block's dynamic block.
|
||||
const BTAG: &str = "AcDbBlockRepBTag";
|
||||
|
||||
fn tag(code: i32, value: impl Into<String>) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
|
||||
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
|
||||
let x = &d.foreign.entities.get(&h)?.xdata;
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
|
||||
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// Each object of `b` keyed by its element tag, or by its position when it has none.
|
||||
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
|
||||
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
|
||||
}
|
||||
|
||||
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
|
||||
fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
|
||||
let key = name.to_ascii_uppercase();
|
||||
d.foreign.targets.iter().find(|(_, (k, n))| k == "BLOCK_RECORD" && *n == key).map(|(h, _)| *h)
|
||||
}
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
match t.code {
|
||||
3 => key = Some(&t.value),
|
||||
350 | 360 => {
|
||||
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
|
||||
out.push((k.to_string(), h));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A parameter's value as a change sets it (block coordinates).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Val {
|
||||
/// A visibility state or lookup value.
|
||||
Text(String),
|
||||
/// A point parameter's position.
|
||||
Point(Vec3),
|
||||
/// A two-point parameter's base and end points.
|
||||
Points(Vec3, Vec3),
|
||||
}
|
||||
|
||||
/// A parameter's current value on the reference: the value it was given, else the block's own.
|
||||
fn current(m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32) -> Option<Val> {
|
||||
let p = m.parameters.iter().find(|p| p.node == node)?;
|
||||
let given = values.get(&node);
|
||||
Some(match (&p.kind, given) {
|
||||
(_, Some(Value::Text { value })) => Val::Text(value.clone()),
|
||||
(_, Some(Value::Point { at })) => Val::Point(*at),
|
||||
(_, Some(Value::Points { base, end })) => Val::Points(*base, *end),
|
||||
(ParameterKind::Visibility { states, .. }, None) => Val::Text(states.first().map(|s| s.name.clone()).unwrap_or_default()),
|
||||
(ParameterKind::Point { at, .. }, None) => Val::Point(*at),
|
||||
(
|
||||
ParameterKind::Linear { base, end, .. }
|
||||
| ParameterKind::Polar { base, end }
|
||||
| ParameterKind::Xy { base, end }
|
||||
| ParameterKind::Rotation { base, end }
|
||||
| ParameterKind::Alignment { base, end }
|
||||
| ParameterKind::Flip { base, end },
|
||||
None,
|
||||
) => Val::Points(*base, *end),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `v` as a value set allows it: the nearest listed value, a multiple of the increment from the
|
||||
/// minimum, within the minimum and maximum.
|
||||
fn allowed(set: &super::ValueSet, v: f64) -> f64 {
|
||||
let mut v = v;
|
||||
if set.is_list() && !set.list.is_empty() {
|
||||
v = set.list.iter().copied().fold(f64::NAN, |best, x| if best.is_nan() || (x - v).abs() < (best - v).abs() { x } else { best });
|
||||
}
|
||||
if set.has_increment() && set.increment > 0.0 {
|
||||
let from = if set.has_minimum() { set.minimum } else { 0.0 };
|
||||
v = from + ((v - from) / set.increment).round() * set.increment;
|
||||
}
|
||||
if set.has_minimum() {
|
||||
v = v.max(set.minimum);
|
||||
}
|
||||
if set.has_maximum() && set.maximum > 0.0 {
|
||||
v = v.min(set.maximum);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A linear parameter's points for distance `dist`: the end point moves along the parameter, or
|
||||
/// both points move apart from the midpoint.
|
||||
fn linear_points(base: Vec3, end: Vec3, dist: f64, from_midpoint: bool) -> (Vec3, Vec3) {
|
||||
let dir = end - base;
|
||||
let len = dir.len();
|
||||
let u = if len > 1e-12 { dir * (1.0 / len) } else { Vec3::new(1.0, 0.0, 0.0) };
|
||||
if from_midpoint {
|
||||
let mid = (base + end) * 0.5;
|
||||
(mid - u * (dist / 2.0), mid + u * (dist / 2.0))
|
||||
} else {
|
||||
(base, base + u * dist)
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes one property change makes, before actions run: parameter values by graph id.
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
set: BTreeMap<i32, Val>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// Set the value a lookup table cell gives property `property` of parameter `node`.
|
||||
fn cell(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str, cell: &str) -> Result<(), String> {
|
||||
let p = m.parameters.iter().find(|p| p.node == node).ok_or("A lookup table refers to a missing parameter.")?;
|
||||
match (&p.kind, property.to_ascii_lowercase().as_str()) {
|
||||
(_, "lookupstring") | (ParameterKind::Visibility { .. }, "visibilitystate") => {
|
||||
self.set.insert(node, Val::Text(cell.to_string()));
|
||||
}
|
||||
(ParameterKind::Linear { from_midpoint, .. }, "updateddistance" | "distance") => {
|
||||
let dist = cell
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| format!("A lookup table gives \"{cell}\" for a distance."))?;
|
||||
let Some(Val::Points(b, e)) = self.set.get(&node).cloned().or_else(|| current(m, values, node)) else { return Ok(()) };
|
||||
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
|
||||
self.set.insert(node, Val::Points(b2, e2));
|
||||
}
|
||||
(_, other) => return Err(format!("A lookup table sets {other}, which CADCraft can't change yet.")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lookup parameter `node` set to `value`: every lookup table that sets it gives its row's
|
||||
/// inputs. A table without that value is left alone; none having it is an error.
|
||||
fn lookup(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, value: &str, skip: Option<usize>) -> Result<bool, String> {
|
||||
let mut found = false;
|
||||
for (i, a) in m.actions.iter().enumerate() {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if Some(i) == skip {
|
||||
continue;
|
||||
}
|
||||
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
|
||||
let Some(row) = rows.iter().find(|r| r.get(out).is_some_and(|v| v.trim().eq_ignore_ascii_case(value.trim()))) else { continue };
|
||||
found = true;
|
||||
for (c, cell) in columns.iter().zip(row) {
|
||||
self.cell(m, values, c.node, &c.property, cell)?;
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// After input property `property` of parameter `node` changed: a lookup table with that input
|
||||
/// whose inputs (with the new value) match a row gives its lookup value, and the lookup's
|
||||
/// other tables then set their inputs for that value (as AutoCAD does).
|
||||
fn chain(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str) -> Result<(), String> {
|
||||
let read = |plan: &Plan, n: i32, prop: &str| -> Option<String> {
|
||||
let v = plan.set.get(&n).cloned().or_else(|| current(m, values, n))?;
|
||||
match (v, prop.to_ascii_lowercase().as_str()) {
|
||||
(Val::Text(t), "visibilitystate" | "lookupstring") => Some(t),
|
||||
(Val::Points(b, e), "updateddistance" | "distance") => Some(format!("{}", (e - b).len())),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for (i, a) in m.actions.iter().enumerate() {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if !columns.iter().any(|c| !c.lookup && c.node == node && c.property.eq_ignore_ascii_case(property)) {
|
||||
continue;
|
||||
}
|
||||
let Some(out) = columns.iter().position(|c| c.lookup) else { continue };
|
||||
let lookup_node = columns.get(out).map_or(0, |c| c.node);
|
||||
let hit = rows.iter().find(|r| {
|
||||
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(j, c)| match (r.get(j), read(self, c.node, &c.property)) {
|
||||
(Some(cell), Some(v)) => same(cell, &v),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
if let Some(v) = hit.and_then(|r| r.get(out)).cloned() {
|
||||
self.set.insert(lookup_node, Val::Text(v.clone()));
|
||||
self.lookup(m, values, lookup_node, &v, Some(i))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
|
||||
fn same(cell: &str, value: &str) -> bool {
|
||||
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
|
||||
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number typed for a property, in drawing units.
|
||||
fn number(value: &str) -> Result<f64, String> {
|
||||
value.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("\"{value}\" is not a number."))
|
||||
}
|
||||
|
||||
/// Set custom property `name` (as the Properties palette names it, any case) of dynamic block
|
||||
/// reference `insert` to `value` (lengths and positions in drawing units, as Properties shows them).
|
||||
pub fn set_property(d: &mut Drawing, insert: Handle, name: &str, value: &str) -> Result<(), String> {
|
||||
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
|
||||
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
|
||||
let (sx, sy) = (ins.scale.x.abs().max(1e-12), ins.scale.y.abs().max(1e-12));
|
||||
let block_base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
|
||||
let want = name.trim();
|
||||
// A point parameter's position is two properties, "Position1 X" and "Position1 Y".
|
||||
let (label, axis) = match want.rsplit_once(' ') {
|
||||
Some((l, a)) if a.eq_ignore_ascii_case("X") || a.eq_ignore_ascii_case("Y") => (l, Some(a.eq_ignore_ascii_case("X"))),
|
||||
_ => (want, None),
|
||||
};
|
||||
let p = m
|
||||
.parameters
|
||||
.iter()
|
||||
.find(|p| {
|
||||
p.show_properties && p.kind.label().is_some_and(|l| l.eq_ignore_ascii_case(want) || (axis.is_some() && l.eq_ignore_ascii_case(label)))
|
||||
})
|
||||
.ok_or_else(|| format!("The reference has no property \"{name}\"."))?;
|
||||
let mut plan = Plan::default();
|
||||
match (&p.kind, axis) {
|
||||
(ParameterKind::Visibility { states, label, .. }, _) => {
|
||||
let s = states.iter().find(|s| s.name.eq_ignore_ascii_case(value.trim())).ok_or_else(|| {
|
||||
format!("\"{value}\" is not a state of {label}: {}.", states.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))
|
||||
})?;
|
||||
plan.set.insert(p.node, Val::Text(s.name.clone()));
|
||||
plan.chain(&m, &r.values, p.node, "VisibilityState")?;
|
||||
}
|
||||
(ParameterKind::Lookup { label, .. }, _) => {
|
||||
plan.set.insert(p.node, Val::Text(value.trim().to_string()));
|
||||
if !plan.lookup(&m, &r.values, p.node, value, None)? {
|
||||
return Err(format!("\"{value}\" is not a value of {label}."));
|
||||
}
|
||||
}
|
||||
(ParameterKind::Linear { values, from_midpoint, .. }, None) => {
|
||||
let typed = number(value)? / sx;
|
||||
if typed <= 0.0 {
|
||||
return Err(format!("{name} must be more than 0."));
|
||||
}
|
||||
let dist = allowed(values, typed);
|
||||
if dist <= 0.0 {
|
||||
return Err(format!("{name} must be more than 0."));
|
||||
}
|
||||
let Some(Val::Points(b, e)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no points.")) };
|
||||
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
|
||||
plan.set.insert(p.node, Val::Points(b2, e2));
|
||||
plan.chain(&m, &r.values, p.node, "UpdatedDistance")?;
|
||||
}
|
||||
(ParameterKind::Point { .. }, Some(is_x)) => {
|
||||
let Some(Val::Point(at)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no position.")) };
|
||||
let v = number(value)?;
|
||||
let at = if is_x { Vec3::new(block_base.x + v / sx, at.y, at.z) } else { Vec3::new(at.x, block_base.y + v / sy, at.z) };
|
||||
plan.set.insert(p.node, Val::Point(at));
|
||||
}
|
||||
_ => return Err(format!("CADCraft can't change {name} yet.")),
|
||||
}
|
||||
apply(d, insert, &r.definition, &r.values, &plan.set, p.node)
|
||||
}
|
||||
|
||||
/// Drag grip `part` of parameter `node` of dynamic block reference `insert` to `to` (drawing
|
||||
/// coordinates), as AutoCAD's dynamic grips do: a distance's end grip (or base grip) changes the
|
||||
/// distance by the drag along the parameter, snapped to its value set (both ends move when the
|
||||
/// distance is measured from its midpoint); a position grip moves the position. The actions and
|
||||
/// lookups follow as for [`set_property`].
|
||||
pub fn drag_grip(d: &mut Drawing, insert: Handle, node: i32, part: super::GripPart, to: Vec2) -> Result<(), String> {
|
||||
use super::GripPart;
|
||||
if !to.is_finite() {
|
||||
return Err("Invalid point.".into());
|
||||
}
|
||||
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
|
||||
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
|
||||
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
|
||||
let q = ins.transform(base).inverse().ok_or("The reference has a zero scale.")?.apply(to);
|
||||
let p = m.parameters.iter().find(|p| p.node == node).ok_or("The reference has no such parameter.")?;
|
||||
let mut plan = Plan::default();
|
||||
match (&p.kind, part) {
|
||||
(ParameterKind::Linear { values, from_midpoint, label, .. }, GripPart::Base | GripPart::End) => {
|
||||
let Some(Val::Points(b, e)) = current(&m, &r.values, node) else { return Err(format!("{label} has no points.")) };
|
||||
let len = (e - b).len();
|
||||
if len < 1e-12 {
|
||||
return Err(format!("{label} has no direction."));
|
||||
}
|
||||
let u = (e - b) * (1.0 / len);
|
||||
let q = Vec3::new(q.x, q.y, b.z);
|
||||
let raw = if *from_midpoint {
|
||||
2.0 * (q - (b + e) * 0.5).dot(u).abs()
|
||||
} else if part == GripPart::End {
|
||||
(q - b).dot(u)
|
||||
} else {
|
||||
(e - q).dot(u)
|
||||
};
|
||||
let dist = allowed(values, raw);
|
||||
if !(raw > 0.0 && dist > 0.0 && dist.is_finite()) {
|
||||
return Err(format!("{label} must be more than 0."));
|
||||
}
|
||||
let (b2, e2) = match (from_midpoint, part) {
|
||||
(true, _) => linear_points(b, e, dist, true),
|
||||
(false, GripPart::Base) => (e - u * dist, e),
|
||||
(false, _) => (b, b + u * dist),
|
||||
};
|
||||
plan.set.insert(node, Val::Points(b2, e2));
|
||||
plan.chain(&m, &r.values, node, "UpdatedDistance")?;
|
||||
}
|
||||
(ParameterKind::Point { label, .. }, GripPart::At) => {
|
||||
let Some(Val::Point(at)) = current(&m, &r.values, node) else { return Err(format!("{label} has no position.")) };
|
||||
plan.set.insert(node, Val::Point(Vec3::new(q.x, q.y, at.z)));
|
||||
}
|
||||
_ => return Err("That grip can't be dragged.".into()),
|
||||
}
|
||||
apply(d, insert, &r.definition, &r.values, &plan.set, node)
|
||||
}
|
||||
|
||||
/// The change of each parameter value an action can follow, by parameter graph id and the
|
||||
/// connection's name ("EndXDelta", "XDelta"…), from `old` and `new` values.
|
||||
fn delta(old: &Val, new: &Val, connection: &str) -> f64 {
|
||||
let c = connection.to_ascii_lowercase();
|
||||
let pick = |v: Vec3| if c.contains('x') { v.x } else { v.y };
|
||||
match (old, new) {
|
||||
(Val::Point(a), Val::Point(b)) => pick(*b - *a),
|
||||
(Val::Points(ab, ae), Val::Points(bb, be)) => {
|
||||
if c.starts_with("base") || c.starts_with("updatedbase") {
|
||||
pick(*bb - *ab)
|
||||
} else {
|
||||
pick(*be - *ae)
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move entity `e` by `v`: the listed vertices of a line or polyline, else the whole object.
|
||||
fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
|
||||
match (&mut e.kind, vertices) {
|
||||
(EntityKind::LwPolyline(pl), Some(idx)) => {
|
||||
for i in idx {
|
||||
if let Some(p) = usize::try_from(*i).ok().and_then(|i| pl.vertices.get_mut(i)) {
|
||||
p.p += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
(EntityKind::Line(l), Some(idx)) => {
|
||||
for i in idx {
|
||||
match i {
|
||||
0 => l.a = l.a + v.to3(0.0),
|
||||
1 => l.b = l.b + v.to3(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => e.kind.transform(&Mat3::translate(v)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
|
||||
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
|
||||
match frame {
|
||||
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
|
||||
f if f.len() >= 3 => {
|
||||
let mut inside = false;
|
||||
let mut j = f.len() - 1;
|
||||
for i in 0..f.len() {
|
||||
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
|
||||
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Give `insert` a new anonymous block with the plan's values applied (visibility states, and
|
||||
/// the stretch and move actions of the changed parameters, with the parameters in their selections
|
||||
/// carried along), and record the new values on it.
|
||||
fn apply(
|
||||
d: &mut Drawing,
|
||||
insert: Handle,
|
||||
definition: &str,
|
||||
values: &BTreeMap<i32, Value>,
|
||||
set: &BTreeMap<i32, Val>,
|
||||
direct: i32,
|
||||
) -> Result<(), String> {
|
||||
let m = dynamic_block(d, definition).ok_or("Not a dynamic block reference.")?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
|
||||
let ins = ins.clone();
|
||||
let def = d.block(definition).cloned().ok_or("The dynamic block is missing.")?;
|
||||
let src = d.block(&ins.block).cloned().ok_or("The reference's block is missing.")?;
|
||||
// The dynamic block's objects and the reference's copies of them, by element tag.
|
||||
let def_tags: HashMap<Handle, i64> = by_tag(d, &def).into_iter().map(|(t, h)| (h, t)).collect();
|
||||
let src_by_tag: HashMap<i64, Handle> = by_tag(d, &src).into_iter().collect();
|
||||
let copy_of = |dh: &Handle| def_tags.get(dh).and_then(|t| src_by_tag.get(t)).copied();
|
||||
// New values, starting from the change; actions add the parameters they carry along.
|
||||
let mut new: BTreeMap<i32, Val> = set.clone();
|
||||
let mut moves: HashMap<Handle, Vec<(Option<Vec<i32>>, Vec2)>> = HashMap::new();
|
||||
let param_of: HashMap<Handle, i32> = m.parameters.iter().map(|p| (p.handle, p.node)).collect();
|
||||
for a in &m.actions {
|
||||
let changed: Vec<&(i32, String)> = a.connections.iter().filter(|(n, _)| set.contains_key(n)).collect();
|
||||
if changed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (mult, angle, frame, vertices): (f64, f64, &[Vec2], &[(Handle, Vec<i32>)]) = match &a.kind {
|
||||
ActionKind::Stretch { frame, vertices, multiplier, angle_offset } => (*multiplier, *angle_offset, frame, vertices),
|
||||
ActionKind::Move { multiplier, angle_offset } => (*multiplier, *angle_offset, &[], &[]),
|
||||
_ => continue,
|
||||
};
|
||||
// The two connections give the change in x and y.
|
||||
let comp = |i: usize| {
|
||||
a.connections.get(i).map_or(0.0, |(n, conn)| match (set.get(n), current(&m, values, *n)) {
|
||||
(Some(nv), Some(ov)) => delta(&ov, nv, conn),
|
||||
_ => 0.0,
|
||||
})
|
||||
};
|
||||
let mult = if mult == 0.0 { 1.0 } else { mult };
|
||||
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||
continue;
|
||||
}
|
||||
for h in &a.selection {
|
||||
if let Some(node) = param_of.get(h) {
|
||||
// A parameter in the selection is carried along: its points in the frame. Which
|
||||
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
|
||||
// the actions from the block itself, so a point another action has moved out of
|
||||
// the frame still follows).
|
||||
if set.contains_key(node) {
|
||||
continue;
|
||||
}
|
||||
let Some(cur) = new.get(node).cloned().or_else(|| current(&m, values, *node)) else { continue };
|
||||
let home = current(&m, &BTreeMap::new(), *node);
|
||||
let shift = |p: Vec3, at: Option<Vec3>| {
|
||||
if frame.is_empty() || in_frame(frame, at.unwrap_or(p).xy()) { p + v.to3(0.0) } else { p }
|
||||
};
|
||||
let moved = match (cur, home) {
|
||||
(Val::Point(p), Some(Val::Point(h))) => Val::Point(shift(p, Some(h))),
|
||||
(Val::Point(p), _) => Val::Point(shift(p, None)),
|
||||
(Val::Points(b, e), Some(Val::Points(hb, he))) => Val::Points(shift(b, Some(hb)), shift(e, Some(he))),
|
||||
(Val::Points(b, e), _) => Val::Points(shift(b, None), shift(e, None)),
|
||||
(t, _) => t,
|
||||
};
|
||||
new.insert(*node, moved);
|
||||
continue;
|
||||
}
|
||||
let Some(c) = copy_of(h) else { continue };
|
||||
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
|
||||
moves.entry(c).or_default().push((idx, v));
|
||||
}
|
||||
}
|
||||
// Visibility states.
|
||||
let mut shown: HashMap<Handle, bool> = HashMap::new();
|
||||
for (node, v) in &new {
|
||||
let (Some(ParameterKind::Visibility { controlled, states, .. }), Val::Text(state)) =
|
||||
(m.parameters.iter().find(|p| p.node == *node).map(|p| &p.kind), v)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let visible: HashSet<Handle> =
|
||||
states.iter().find(|s| s.name.eq_ignore_ascii_case(state)).map(|s| s.visible.iter().copied().collect()).unwrap_or_default();
|
||||
for h in controlled {
|
||||
shown.insert(*h, visible.contains(h));
|
||||
}
|
||||
}
|
||||
let shown_copy: HashMap<Handle, bool> = shown.iter().filter_map(|(dh, v)| copy_of(dh).map(|c| (c, *v))).collect();
|
||||
// The new anonymous block.
|
||||
let name = d.anonymous_block_name("*U");
|
||||
// AutoCAD's anonymous copies have no units of their own.
|
||||
let mut b =
|
||||
Block { name: name.clone(), anonymous: true, xref_path: None, array: None, units: 0, entities: crate::EntityStore::new(), ..(*src).clone() };
|
||||
let mut copies: HashMap<Handle, Handle> = HashMap::new();
|
||||
for e in src.entities.iter() {
|
||||
copies.insert(e.handle, d.new_handle());
|
||||
}
|
||||
// How far each attribute definition moves (block coordinates): the reference's attribute of
|
||||
// that tag moves with it, as AutoCAD moves it.
|
||||
let attrib_moves: HashMap<String, Vec2> = src
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(ad) => {
|
||||
let v = moves.get(&e.handle).into_iter().flatten().fold(Vec2::ZERO, |acc, (_, v)| acc + *v);
|
||||
(v != Vec2::ZERO).then(|| (ad.tag.to_ascii_uppercase(), v))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for e in src.entities.iter() {
|
||||
let Some(nh) = copies.get(&e.handle).copied() else { continue };
|
||||
let mut c: Entity = (**e).clone();
|
||||
c.handle = nh;
|
||||
if let Some(v) = shown_copy.get(&e.handle) {
|
||||
c.common.visible = *v;
|
||||
}
|
||||
for (idx, v) in moves.get(&e.handle).into_iter().flatten() {
|
||||
move_entity(&mut c, idx.as_deref(), *v);
|
||||
}
|
||||
if let EntityKind::Hatch(h) = &mut c.kind {
|
||||
for l in &mut h.loops {
|
||||
l.sources = l.sources.iter().map(|s| copies.get(s).copied().unwrap_or(*s)).collect();
|
||||
}
|
||||
}
|
||||
b.entities.push(c);
|
||||
}
|
||||
refill_hatches(&mut b);
|
||||
let def_record = Some(record_handle(d, definition).unwrap_or_else(|| {
|
||||
let h = d.new_handle();
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.targets.insert(h, ("BLOCK_RECORD".to_string(), definition.to_ascii_uppercase()));
|
||||
h
|
||||
}));
|
||||
let old = ins.block.clone();
|
||||
d.blocks.insert(name.clone(), std::sync::Arc::new(b));
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
for (old_h, new_h) in &copies {
|
||||
if let Some(mut x) = f.entities.get(old_h).cloned() {
|
||||
for t in &mut x.xdata {
|
||||
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(*old_h) {
|
||||
t.value = new_h.hex();
|
||||
}
|
||||
}
|
||||
x.xdict = None;
|
||||
// Reactors within the block (an associative hatch's outline) follow to the copies;
|
||||
// ones outside it are not ours.
|
||||
x.reactors = x.reactors.iter().filter_map(|r| copies.get(r).copied()).collect();
|
||||
f.entities.insert(*new_h, x);
|
||||
}
|
||||
}
|
||||
if let Some(rec) = def_record {
|
||||
let xdata = vec![tag(1001, BTAG), tag(1070, "1"), tag(1005, rec.hex())];
|
||||
f.records.insert(("BLOCK_RECORD".into(), name.to_ascii_uppercase()), ForeignExtra { xdata, ..Default::default() });
|
||||
}
|
||||
// The block-to-drawing transform's linear part turns a block-coordinate move into the move of
|
||||
// the reference's attribute.
|
||||
let to_world = ins.transform(src.base.xy());
|
||||
let origin = to_world.apply(Vec2::ZERO);
|
||||
d.modify_entity(insert, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.block = name.clone();
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = attrib_moves.get(&a.tag.to_ascii_uppercase()) {
|
||||
let w = to_world.apply(*v) - origin;
|
||||
let mut k = EntityKind::Text(a.text.clone());
|
||||
k.transform(&Mat3::translate(w));
|
||||
if let EntityKind::Text(t) = k {
|
||||
a.text = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let was = current(&m, values, direct);
|
||||
record_values(d, insert, def_record, &m, &new, (direct, was.as_ref()));
|
||||
drop_if_unused(d, &old);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
|
||||
/// outline again (it may have been stretched).
|
||||
fn refill_hatches(b: &mut Block) {
|
||||
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::LwPolyline(pl) if pl.closed => Some((e.handle, pl.vertices.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let hatches: Vec<Handle> = b.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Hatch(h) if h.associative)).map(|e| e.handle).collect();
|
||||
for h in hatches {
|
||||
b.entities.modify(h, |e| {
|
||||
if let EntityKind::Hatch(hh) = &mut e.kind {
|
||||
for l in &mut hh.loops {
|
||||
if let [s] = l.sources.as_slice()
|
||||
&& let Some(vs) = outlines.get(s)
|
||||
{
|
||||
l.vertices.clone_from(vs);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The value records of the references to the dynamic block whose record is `def_record` (from
|
||||
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
|
||||
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
|
||||
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
|
||||
// A representation dictionary whose data names this dynamic block.
|
||||
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
|
||||
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
|
||||
continue;
|
||||
}
|
||||
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
|
||||
for (k, h) in entries(d, values) {
|
||||
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
|
||||
out.entry(k).or_insert_with(|| x.tags.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A dictionary object `h` owned by `owner` with `entries`.
|
||||
fn dictionary(h: Handle, owner: Handle, entries: &[(&str, Handle)]) -> ForeignObject {
|
||||
let mut tags = vec![tag(5, h.hex()), tag(330, owner.hex()), tag(100, "AcDbDictionary"), tag(280, "1"), tag(281, "1")];
|
||||
for (k, v) in entries {
|
||||
tags.push(tag(3, *k));
|
||||
tags.push(tag(360, v.hex()));
|
||||
}
|
||||
ForeignObject { handle: h, kind: "DICTIONARY".into(), tags }
|
||||
}
|
||||
|
||||
/// `tags` (a template's groups) as new record `h` owned by `owner`: the template's handle, owner
|
||||
/// and reactors are not ours (inside reactor brackets, 102, everything goes).
|
||||
fn own(mut tags: Vec<RawTag>, h: Handle, owner: Handle) -> Vec<RawTag> {
|
||||
let mut inside = false;
|
||||
tags.retain(|t| {
|
||||
if t.code == 102 {
|
||||
inside = t.value.starts_with('{');
|
||||
return false;
|
||||
}
|
||||
!(inside || t.code == 5 || t.code == 330)
|
||||
});
|
||||
let at = tags.iter().position(|t| t.code == 100).unwrap_or(0);
|
||||
tags.insert(at, tag(330, owner.hex()));
|
||||
tags.insert(0, tag(5, h.hex()));
|
||||
tags
|
||||
}
|
||||
|
||||
/// Record values `set` (by parameter graph id) in `insert`'s representation data, and the direct
|
||||
/// change (of parameter `direct`, from value `old`) in its history, creating them when the reference has none. A
|
||||
/// new reference's data gets a record for everything other references of the dynamic block have
|
||||
/// (AutoCAD keeps one for each parameter and stretch action), with its own values; with no other
|
||||
/// reference to copy, a changed value gets a minimal record.
|
||||
fn record_values(
|
||||
d: &mut Drawing,
|
||||
insert: Handle,
|
||||
def_record: Option<Handle>,
|
||||
m: &DynamicBlock,
|
||||
set: &BTreeMap<i32, Val>,
|
||||
(direct, old): (i32, Option<&Val>),
|
||||
) {
|
||||
let xdict = d.foreign.entities.get(&insert).and_then(|x| x.xdict);
|
||||
let rep = xdict.and_then(|x| entry(d, x, "AcDbBlockRepresentation"));
|
||||
let cache = rep.and_then(|r| entry(d, r, "AppDataCache"));
|
||||
let data = cache.and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA"));
|
||||
let templates = def_record.map(|r| templates(d, r)).unwrap_or_default();
|
||||
let param = |node: i32| m.parameters.iter().find(|p| p.node == node).map(|p| &p.kind);
|
||||
let at = |node: i32| match param(node) {
|
||||
Some(
|
||||
ParameterKind::Visibility { at, .. }
|
||||
| ParameterKind::Lookup { at, .. }
|
||||
| ParameterKind::Point { at, .. }
|
||||
| ParameterKind::BasePoint { at },
|
||||
) => *at,
|
||||
_ => Vec3::ZERO,
|
||||
};
|
||||
let mut objects: Vec<ForeignObject> = Vec::new();
|
||||
let mut fresh: Vec<(String, Vec<RawTag>)> = Vec::new();
|
||||
let (cache, data) = match (cache, data) {
|
||||
(Some(c), Some(h)) => (c, h),
|
||||
_ => {
|
||||
// Create the representation: reference xdict ▸ AcDbBlockRepresentation ▸ AcDbRepData
|
||||
// (the dynamic block's record) and AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA.
|
||||
let Some(rec) = def_record else { return };
|
||||
let xd = xdict.unwrap_or_else(|| d.new_handle());
|
||||
let (rep, rd, cache, data) = (d.new_handle(), d.new_handle(), d.new_handle(), d.new_handle());
|
||||
objects.push(dictionary(rep, xd, &[("AcDbRepData", rd), ("AppDataCache", cache)]));
|
||||
objects.push(ForeignObject {
|
||||
handle: rd,
|
||||
kind: "ACDB_BLOCKREPRESENTATION_DATA".into(),
|
||||
tags: vec![tag(5, rd.hex()), tag(330, rep.hex()), tag(100, "AcDbBlockRepresentationData"), tag(70, "1"), tag(340, rec.hex())],
|
||||
});
|
||||
objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", data)]));
|
||||
objects.push(dictionary(data, cache, &[]));
|
||||
// Every record the other references have, with this reference's values: its
|
||||
// parameters' positions and defaults (lookups as Properties shows them); action
|
||||
// records as they are.
|
||||
let lookups: HashMap<String, String> = super::custom_properties(d, insert)
|
||||
.map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.value)).collect())
|
||||
.unwrap_or_default();
|
||||
let mut keys: Vec<&String> = templates.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
let (Ok(node), Some(t)) = (k.parse::<i32>(), templates.get(k)) else { continue };
|
||||
let mut tags = t.clone();
|
||||
match param(node) {
|
||||
Some(ParameterKind::Visibility { .. } | ParameterKind::Point { .. } | ParameterKind::BasePoint { .. }) => {
|
||||
write_value(&mut tags, &Val::Point(at(node)));
|
||||
}
|
||||
Some(ParameterKind::Lookup { label, .. }) => {
|
||||
write_value(&mut tags, &Val::Point(at(node)));
|
||||
if let Some(v) = lookups.get(&label.to_ascii_uppercase()) {
|
||||
write_value(&mut tags, &Val::Text(v.clone()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = current(m, &BTreeMap::new(), node) {
|
||||
write_value(&mut tags, &v);
|
||||
}
|
||||
fresh.push((k.clone(), tags));
|
||||
}
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
match f.objects.iter_mut().find(|o| Some(o.handle) == xdict) {
|
||||
Some(x) => {
|
||||
x.tags.push(tag(3, "AcDbBlockRepresentation"));
|
||||
x.tags.push(tag(360, rep.hex()));
|
||||
}
|
||||
None => {
|
||||
objects.push(dictionary(xd, insert, &[("AcDbBlockRepresentation", rep)]));
|
||||
f.entities.entry(insert).or_default().xdict = Some(xd);
|
||||
}
|
||||
}
|
||||
(cache, data)
|
||||
}
|
||||
};
|
||||
let existing: HashMap<String, Handle> = match objects.iter().find(|o| o.handle == data) {
|
||||
Some(_) => HashMap::new(),
|
||||
None => entries(d, data).into_iter().collect(),
|
||||
};
|
||||
let mut add: Vec<(String, Handle)> = Vec::new();
|
||||
for (key, mut tags) in fresh {
|
||||
if let Some(v) = key.parse::<i32>().ok().and_then(|n| set.get(&n)) {
|
||||
write_value(&mut tags, v);
|
||||
}
|
||||
let h = d.new_handle();
|
||||
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
|
||||
add.push((key, h));
|
||||
}
|
||||
for (node, v) in set {
|
||||
let key = node.to_string();
|
||||
if add.iter().any(|(k, _)| *k == key) {
|
||||
continue;
|
||||
}
|
||||
match existing.get(&key) {
|
||||
Some(h) => {
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == *h) {
|
||||
write_value(&mut x.tags, v);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let h = d.new_handle();
|
||||
let mut tags = templates.get(&key).cloned().unwrap_or_else(|| {
|
||||
let p = at(*node);
|
||||
vec![tag(100, "AcDbXrecord"), tag(280, "1"), tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]
|
||||
});
|
||||
write_value(&mut tags, v);
|
||||
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
|
||||
add.push((key, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The history: AutoCAD replays it over the dynamic block (when the block is redefined), so
|
||||
// it must say what was done to this reference.
|
||||
let history = entry(d, cache, "ACAD_ENHANCEDBLOCKHISTORY").filter(|h| d.foreign.objects.iter().any(|o| o.handle == *h));
|
||||
let mut new_history = None;
|
||||
if let (Some(v), Some(p)) = (set.get(&direct), m.parameters.iter().find(|p| p.node == direct)) {
|
||||
match history {
|
||||
Some(h) => {
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == h) {
|
||||
write_history(&mut x.tags, p, old, v);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let h = d.new_handle();
|
||||
let mut tags = vec![tag(5, h.hex()), tag(330, cache.hex()), tag(100, "AcDbXrecord"), tag(280, "1"), tag(1070, "3")];
|
||||
write_history(&mut tags, p, old, v);
|
||||
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags });
|
||||
new_history = Some(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.objects.extend(objects);
|
||||
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == data) {
|
||||
for (k, h) in add {
|
||||
x.tags.push(tag(3, k));
|
||||
x.tags.push(tag(360, h.hex()));
|
||||
}
|
||||
}
|
||||
if let (Some(h), Some(x)) = (new_history, f.objects.iter_mut().find(|o| o.handle == cache)) {
|
||||
x.tags.push(tag(3, "ACAD_ENHANCEDBLOCKHISTORY"));
|
||||
x.tags.push(tag(360, h.hex()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the direct change of parameter `p` to `v` in a reference's history (groups `tags`):
|
||||
/// after its header (`280 1`, `1070 3`), an entry per change: `1071` the id, `300` what, then
|
||||
/// the value. A visibility state or lookup value goes by the property's name (`1` the text), as
|
||||
/// does a distance (`40`, in block units: typed in Properties, it replaces its grips' drags); a
|
||||
/// position as the drag of its grip, `GRIPLOC` with how far it is from where the dynamic block
|
||||
/// has it (`11/21/31`), when the change moves it (from `old`). An entry for the same id is
|
||||
/// replaced; a grip back in its place has none.
|
||||
fn write_history(tags: &mut Vec<RawTag>, p: &super::Parameter, old: Option<&Val>, v: &Val) {
|
||||
let grip = |id: i32, moved: Vec3| {
|
||||
let home = moved.x.abs() < 1e-9 && moved.y.abs() < 1e-9 && moved.z.abs() < 1e-9;
|
||||
let e = vec![
|
||||
tag(1071, id.to_string()),
|
||||
tag(300, "GRIPLOC"),
|
||||
tag(11, moved.x.to_string()),
|
||||
tag(21, moved.y.to_string()),
|
||||
tag(31, moved.z.to_string()),
|
||||
];
|
||||
(id, if home { Vec::new() } else { e })
|
||||
};
|
||||
// Each grip with where it was, where it is and where the dynamic block has it.
|
||||
let grips = |places: &[(Vec3, Vec3, Vec3)]| -> Vec<(i32, Vec<RawTag>)> {
|
||||
p.grips
|
||||
.iter()
|
||||
.zip(places)
|
||||
.filter(|(g, (was, is, _))| **g != 0 && (*was - *is).len() > 1e-9)
|
||||
.map(|(g, (_, is, home))| grip(*g, *is - *home))
|
||||
.collect()
|
||||
};
|
||||
let was = match old {
|
||||
Some(Val::Point(q)) => Some(*q),
|
||||
_ => None,
|
||||
};
|
||||
let changes: Vec<(i32, Vec<RawTag>)> = match (&p.kind, v) {
|
||||
(ParameterKind::Visibility { label, .. } | ParameterKind::Lookup { label, .. }, Val::Text(t)) => {
|
||||
vec![(p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(1, t.clone())])]
|
||||
}
|
||||
(ParameterKind::Linear { label, .. }, Val::Points(b, e)) => {
|
||||
let mut c: Vec<(i32, Vec<RawTag>)> = p.grips.iter().filter(|g| **g != 0).map(|g| (*g, Vec::new())).collect();
|
||||
c.push((p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(40, (*e - *b).len().to_string())]));
|
||||
c
|
||||
}
|
||||
(ParameterKind::Point { at, .. }, Val::Point(q)) => grips(&[(was.unwrap_or(*at), *q, *at)]),
|
||||
_ => return,
|
||||
};
|
||||
// The header, then the entries (each from its 1071).
|
||||
let start = tags.iter().position(|t| t.code == 1071).unwrap_or(tags.len());
|
||||
let mut entries: Vec<Vec<RawTag>> = Vec::new();
|
||||
for t in tags.drain(start..) {
|
||||
if t.code == 1071 || entries.is_empty() {
|
||||
entries.push(Vec::new());
|
||||
}
|
||||
if let Some(e) = entries.last_mut() {
|
||||
e.push(t);
|
||||
}
|
||||
}
|
||||
let id = |e: &[RawTag]| e.first().filter(|t| t.code == 1071).and_then(|t| t.value.trim().parse::<i32>().ok());
|
||||
entries.retain(|e| !changes.iter().any(|(c, _)| id(e) == Some(*c)));
|
||||
entries.extend(changes.into_iter().map(|(_, e)| e).filter(|e| !e.is_empty()));
|
||||
tags.extend(entries.into_iter().flatten());
|
||||
}
|
||||
|
||||
/// Write value `v` into a value record's groups: its text (group 1), or its points in order over
|
||||
/// the record's points (10/20/30), adding them when it has fewer.
|
||||
fn write_value(tags: &mut Vec<RawTag>, v: &Val) {
|
||||
let points: Vec<Vec3> = match v {
|
||||
Val::Text(t) => {
|
||||
match tags.iter_mut().find(|x| x.code == 1) {
|
||||
Some(x) => x.value.clone_from(t),
|
||||
None => tags.push(tag(1, t.clone())),
|
||||
}
|
||||
return;
|
||||
}
|
||||
Val::Point(p) => vec![*p],
|
||||
Val::Points(b, e) => vec![*b, *e],
|
||||
};
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
// The i-th point's groups, else new ones before any text.
|
||||
let find = |tags: &[RawTag], code: i32, n: usize| tags.iter().enumerate().filter(|(_, t)| t.code == code).nth(n).map(|(j, _)| j);
|
||||
match (find(tags, 10, i), find(tags, 20, i)) {
|
||||
(Some(jx), Some(jy)) => {
|
||||
if let Some(t) = tags.get_mut(jx) {
|
||||
t.value = p.x.to_string();
|
||||
}
|
||||
if let Some(t) = tags.get_mut(jy) {
|
||||
t.value = p.y.to_string();
|
||||
}
|
||||
if let Some(t) = find(tags, 30, i).and_then(|jz| tags.get_mut(jz)) {
|
||||
t.value = p.z.to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let at = tags.iter().position(|t| t.code == 1).unwrap_or(tags.len());
|
||||
tags.splice(at..at, [tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove anonymous block `name` when nothing refers to it any more (the block a changed
|
||||
/// reference used to show), with the data kept for its record.
|
||||
fn drop_if_unused(d: &mut Drawing, name: &str) {
|
||||
if !name.starts_with('*') {
|
||||
return;
|
||||
}
|
||||
let refers = |e: &Entity| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name));
|
||||
let used = d.model.iter().any(|e| refers(e))
|
||||
|| d.layouts.iter().any(|l| l.entities.iter().any(|e| refers(e)))
|
||||
|| d.blocks.values().any(|b| b.entities.iter().any(|e| refers(e)));
|
||||
if used {
|
||||
return;
|
||||
}
|
||||
if let Some(b) = d.blocks.remove(name) {
|
||||
let f = std::sync::Arc::make_mut(&mut d.foreign);
|
||||
f.records.remove(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()));
|
||||
f.records.remove(&("BLOCK".to_string(), name.to_ascii_uppercase()));
|
||||
for e in b.entities.iter() {
|
||||
f.entities.remove(&e.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! The grips of a dynamic block reference, as AutoCAD shows them on a selected reference: an
|
||||
//! arrow at each end of a distance (linear parameter), a square at a position (point parameter)
|
||||
//! and a down-pointing triangle for a list (visibility states, lookup values). Dragging an arrow
|
||||
//! or square is [`super::drag_grip`]; a list grip offers its property's choices.
|
||||
//!
|
||||
//! With a visibility parameter, a state lists the grips shown in it; the others are hidden.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{DynamicBlock, ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most grips listed for one reference.
|
||||
const MAX_GRIPS: usize = 200;
|
||||
|
||||
/// Which of a parameter's points a grip is at.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripPart {
|
||||
/// A distance's base point.
|
||||
Base,
|
||||
/// A distance's end point.
|
||||
End,
|
||||
/// A one-point parameter's point (a position, a visibility or lookup grip).
|
||||
At,
|
||||
}
|
||||
|
||||
impl GripPart {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GripPart::Base => "base",
|
||||
GripPart::End => "end",
|
||||
GripPart::At => "at",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<GripPart> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"base" => Some(GripPart::Base),
|
||||
"end" => Some(GripPart::End),
|
||||
"at" => Some(GripPart::At),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grip looks and what it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripShape {
|
||||
/// An arrow pointing away from the distance: drag it to change the distance.
|
||||
Linear,
|
||||
/// A square: drag it to move the position.
|
||||
Point,
|
||||
/// A down-pointing triangle: click it for the property's choices.
|
||||
List,
|
||||
}
|
||||
|
||||
/// One grip of a dynamic block reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynGrip {
|
||||
/// The parameter's graph id.
|
||||
pub node: i32,
|
||||
pub part: GripPart,
|
||||
pub shape: GripShape,
|
||||
/// Where it is, in drawing coordinates.
|
||||
#[serde(serialize_with = "point")]
|
||||
pub at: Vec2,
|
||||
/// The way an arrow points (a unit vector in drawing coordinates; zero for the others).
|
||||
#[serde(serialize_with = "point")]
|
||||
pub dir: Vec2,
|
||||
/// The property it changes, as Properties names it.
|
||||
pub property: String,
|
||||
/// A list grip's choices.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
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)
|
||||
}
|
||||
|
||||
/// The grips of dynamic block reference `insert` (none when it is not one).
|
||||
pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
let Some(r) = reference(d, insert) else { return Vec::new() };
|
||||
let Some(m) = dynamic_block(d, &r.definition) else { return Vec::new() };
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(insert).map(|e| &e.kind) else { return Vec::new() };
|
||||
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
|
||||
let xf = ins.transform(base);
|
||||
let world = |p: Vec3| xf.apply(p.xy());
|
||||
let toward = |p: Vec3, u: Vec3| {
|
||||
let v = xf.apply(p.xy() + u.xy()) - xf.apply(p.xy());
|
||||
if v.len() > 1e-12 { v * (1.0 / v.len()) } else { Vec2::ZERO }
|
||||
};
|
||||
// The grips the current visibility state shows (all of them without visibility states).
|
||||
let objects: HashMap<i32, Handle> = m.grip_objects.iter().copied().collect();
|
||||
let mut shown_in_state: Option<HashSet<Handle>> = None;
|
||||
let mut visibility_node = None;
|
||||
for p in &m.parameters {
|
||||
if let ParameterKind::Visibility { states, .. } = &p.kind {
|
||||
let current = match r.values.get(&p.node) {
|
||||
Some(Value::Text { value }) => states.iter().find(|s| s.name.eq_ignore_ascii_case(value)),
|
||||
_ => states.first(),
|
||||
};
|
||||
if let Some(s) = current {
|
||||
shown_in_state = Some(s.active.iter().copied().collect());
|
||||
}
|
||||
visibility_node = Some(p.node);
|
||||
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)) || 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();
|
||||
let mut out = Vec::new();
|
||||
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, 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 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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
ParameterKind::Point { at, label, .. } => {
|
||||
let at = match r.values.get(&p.node) {
|
||||
Some(Value::Point { at }) => *at,
|
||||
_ => *at,
|
||||
};
|
||||
if shown(p.node, grip(0)) {
|
||||
let at = world(at);
|
||||
out.push(DynGrip {
|
||||
node: p.node,
|
||||
part: GripPart::At,
|
||||
shape: GripShape::Point,
|
||||
at,
|
||||
dir: Vec2::ZERO,
|
||||
property: label.clone(),
|
||||
choices: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
ParameterKind::Visibility { at, label, .. } | ParameterKind::Lookup { at, label, .. } => {
|
||||
let Some(list) = choices.get(&label.to_ascii_uppercase()).filter(|c| !c.is_empty()) else { continue };
|
||||
if shown(p.node, grip(0)) {
|
||||
let at = world(*at);
|
||||
out.push(DynGrip {
|
||||
node: p.node,
|
||||
part: GripPart::At,
|
||||
shape: GripShape::List,
|
||||
at,
|
||||
dir: Vec2::ZERO,
|
||||
property: label.clone(),
|
||||
choices: list.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if out.len() >= MAX_GRIPS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.retain(|g| g.at.is_finite());
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Dynamic block references: which dynamic block a block reference shows, the values it was given
|
||||
//! and the custom properties those make.
|
||||
//!
|
||||
//! A reference whose values differ from the block's own refers to an anonymous block (`*U12`)
|
||||
//! holding the result, and its extension dictionary says what it is: `AcDbBlockRepresentation`
|
||||
//! has `AcDbRepData` (pointing at the dynamic block's BLOCK_RECORD) and an `AppDataCache` whose
|
||||
//! `ACAD_ENHANCEDBLOCKDATA` dictionary holds one XRECORD per parameter, keyed by its graph id, with
|
||||
//! the value in block coordinates. A reference to the dynamic block itself has the block's values.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ActionKind, DynamicBlock, ParameterKind, dynamic_block};
|
||||
use crate::foreign::ForeignObject;
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most values one reference is read with.
|
||||
const MAX_VALUES: usize = 10_000;
|
||||
|
||||
/// A parameter's value on one reference, in block coordinates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum Value {
|
||||
/// A visibility state's name or a lookup value.
|
||||
Text { value: String },
|
||||
/// A point parameter's position.
|
||||
Point { at: Vec3 },
|
||||
/// A two-point parameter's points.
|
||||
Points { base: Vec3, end: Vec3 },
|
||||
}
|
||||
|
||||
/// A dynamic block reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Reference {
|
||||
/// The dynamic block it shows (its name in the drawing).
|
||||
pub definition: String,
|
||||
/// The values it was given, by parameter graph id; parameters missing here have the block's
|
||||
/// own values.
|
||||
pub values: BTreeMap<i32, Value>,
|
||||
}
|
||||
|
||||
/// One custom property of a reference, as the Properties palette shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
/// The values it may be set to, when it is a choice (visibility states, lookup values, a
|
||||
/// distance's list).
|
||||
pub choices: Vec<String>,
|
||||
/// CADCraft can change it ([`super::set_property`]): a visibility state, a distance, a
|
||||
/// position, or a lookup whose tables set only those.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
fn objects(d: &Drawing) -> HashMap<Handle, &ForeignObject> {
|
||||
d.foreign.objects.iter().map(|o| (o.handle, o)).collect()
|
||||
}
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(objects: &HashMap<Handle, &ForeignObject>, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = objects.get(&dict)?;
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(objects: &HashMap<Handle, &ForeignObject>, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = objects.get(&dict) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
match t.code {
|
||||
3 => key = Some(t.value.as_str()),
|
||||
350 | 360 => {
|
||||
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
|
||||
out.push((k.to_string(), h));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A value XRECORD: its text (group 1) when it has one, else its points (10/20/30).
|
||||
fn value(o: &ForeignObject) -> Option<Value> {
|
||||
if let Some(t) = o.tags.iter().find(|t| t.code == 1) {
|
||||
return Some(Value::Text { value: t.value.clone() });
|
||||
}
|
||||
let num = |code: i32, i: usize| {
|
||||
o.tags.iter().filter(|t| t.code == code).nth(i).and_then(|t| t.value.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
};
|
||||
let point = |i: usize| Some(Vec3::new(num(10, i)?, num(20, i)?, num(30, i).unwrap_or(0.0)));
|
||||
match (point(0), point(1)) {
|
||||
(Some(base), Some(end)) => Some(Value::Points { base, end }),
|
||||
(Some(at), None) => Some(Value::Point { at }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dynamic block reference `insert` is, or `None` when it isn't one.
|
||||
pub fn reference(d: &Drawing, insert: Handle) -> Option<Reference> {
|
||||
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
|
||||
let objects = objects(d);
|
||||
let rep = d.foreign.entities.get(&insert).and_then(|x| x.xdict).and_then(|x| entry(&objects, x, "AcDbBlockRepresentation"));
|
||||
let Some(rep) = rep else {
|
||||
// No representation: a reference to a dynamic block with its own values.
|
||||
return dynamic_block(d, &ins.block)
|
||||
.is_some()
|
||||
.then(|| Reference { definition: d.block(&ins.block).map_or_else(|| ins.block.clone(), |b| b.name.clone()), values: BTreeMap::new() });
|
||||
};
|
||||
let data = entry(&objects, rep, "AcDbRepData").and_then(|h| objects.get(&h).copied())?;
|
||||
let target = data.tags.iter().find(|t| t.code == 340).and_then(|t| Handle::parse_hex(&t.value))?;
|
||||
let (kind, name) = d.foreign.targets.get(&target)?;
|
||||
let definition = d.block(name).filter(|_| kind == "BLOCK_RECORD")?.name.clone();
|
||||
dynamic_block(d, &definition)?;
|
||||
let mut values = BTreeMap::new();
|
||||
let cache = entry(&objects, rep, "AppDataCache").and_then(|c| entry(&objects, c, "ACAD_ENHANCEDBLOCKDATA"));
|
||||
for (key, h) in cache.map(|c| entries(&objects, c)).unwrap_or_default().into_iter().take(MAX_VALUES) {
|
||||
if let (Ok(node), Some(v)) = (key.trim().parse::<i32>(), objects.get(&h).and_then(|o| value(o))) {
|
||||
values.insert(node, v);
|
||||
}
|
||||
}
|
||||
Some(Reference { definition, values })
|
||||
}
|
||||
|
||||
/// A number as the drawing shows lengths (LUPREC decimals).
|
||||
fn number(d: &Drawing, v: f64) -> String {
|
||||
let prec = usize::try_from(d.header.i64("LUPREC", 4).clamp(0, 8)).unwrap_or(4);
|
||||
format!("{v:.prec$}")
|
||||
}
|
||||
|
||||
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
|
||||
fn same(cell: &str, value: &str) -> bool {
|
||||
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
|
||||
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current values of a reference's parameters, for evaluating its lookup tables.
|
||||
struct Current<'a> {
|
||||
m: &'a DynamicBlock,
|
||||
values: &'a BTreeMap<i32, Value>,
|
||||
}
|
||||
|
||||
impl Current<'_> {
|
||||
/// Parameter `node`'s value of property `property`, as table cells write it.
|
||||
fn property(&self, node: i32, property: &str) -> Option<String> {
|
||||
let p = self.m.parameters.iter().find(|p| p.node == node)?;
|
||||
let v = self.values.get(&node);
|
||||
match (&p.kind, property.to_ascii_lowercase().as_str()) {
|
||||
(ParameterKind::Visibility { states, .. }, "visibilitystate") => match v {
|
||||
Some(Value::Text { value }) => Some(value.clone()),
|
||||
_ => states.first().map(|s| s.name.clone()),
|
||||
},
|
||||
(ParameterKind::Linear { base, end, .. }, "updateddistance" | "distance") => {
|
||||
let (b, e) = match v {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
Some(format!("{}", (e - b).len()))
|
||||
}
|
||||
(ParameterKind::Lookup { .. }, "lookupstring") => match v {
|
||||
Some(Value::Text { value }) => Some(value.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup parameter `node`'s value: the value every lookup table that sets it gives for the
|
||||
/// current inputs, or the tables' "no match" name ("Custom") when they don't agree or a table
|
||||
/// has no matching row. Also the values it may be set to.
|
||||
fn lookup(&self, node: i32) -> (String, Vec<String>) {
|
||||
let mut found: Option<Option<String>> = None;
|
||||
let mut unmatched = String::from("Custom");
|
||||
let mut choices: Vec<String> = Vec::new();
|
||||
for a in &self.m.actions {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = columns.get(out) {
|
||||
unmatched = c.unmatched.clone();
|
||||
}
|
||||
for r in rows {
|
||||
if let Some(v) = r.get(out).filter(|v| !choices.contains(v)) {
|
||||
choices.push(v.clone());
|
||||
}
|
||||
}
|
||||
let hit = rows.iter().find(|r| {
|
||||
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(i, c)| match (r.get(i), self.property(c.node, &c.property)) {
|
||||
(Some(cell), Some(v)) => same(cell, &v),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
let v = hit.and_then(|r| r.get(out).cloned());
|
||||
found = Some(match found {
|
||||
None => v,
|
||||
Some(prev) if prev == v => prev,
|
||||
Some(_) => None,
|
||||
});
|
||||
}
|
||||
(found.flatten().unwrap_or(unmatched), choices)
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom properties block reference `insert` shows (the Properties palette's Custom
|
||||
/// section): its dynamic block's name and, in parameter order, each parameter shown in Properties
|
||||
/// with its value. Lengths and positions are in drawing units (block units × the reference's
|
||||
/// scale); positions are relative to the block's base point.
|
||||
pub fn custom_properties(d: &Drawing, insert: Handle) -> Option<(String, Vec<CustomProperty>)> {
|
||||
let r = reference(d, insert)?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
|
||||
let m = dynamic_block(d, &r.definition)?;
|
||||
let base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
|
||||
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
|
||||
let cur = Current { m: &m, values: &r.values };
|
||||
let mut out = Vec::new();
|
||||
for p in m.parameters.iter().filter(|p| p.show_properties) {
|
||||
let v = r.values.get(&p.node);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base: b, end: e, label, values, .. } => {
|
||||
let (b, e) = match v {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*b, *e),
|
||||
};
|
||||
let choices = if values.is_list() { values.list.iter().map(|x| number(d, x * sx)).collect() } else { Vec::new() };
|
||||
out.push(CustomProperty { name: label.clone(), value: number(d, (e - b).len() * sx), choices, editable: true });
|
||||
}
|
||||
ParameterKind::Point { at, label, .. } => {
|
||||
let at = match v {
|
||||
Some(Value::Point { at }) => *at,
|
||||
_ => *at,
|
||||
};
|
||||
out.push(CustomProperty { name: format!("{label} X"), value: number(d, (at.x - base.x) * sx), choices: Vec::new(), editable: true });
|
||||
out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new(), editable: true });
|
||||
}
|
||||
ParameterKind::Visibility { label, states, .. } => {
|
||||
let value = cur.property(p.node, "VisibilityState").unwrap_or_default();
|
||||
out.push(CustomProperty { name: label.clone(), value, choices: states.iter().map(|s| s.name.clone()).collect(), editable: true });
|
||||
}
|
||||
ParameterKind::Lookup { label, .. } => {
|
||||
let (value, choices) = cur.lookup(p.node);
|
||||
let editable = lookup_sets_what_changes(&m, p.node);
|
||||
out.push(CustomProperty { name: label.clone(), value, choices, editable });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((r.definition, out))
|
||||
}
|
||||
|
||||
/// Whether every lookup table that sets lookup parameter `node` sets only what
|
||||
/// [`super::set_property`] can apply with it: visibility states and distances.
|
||||
fn lookup_sets_what_changes(m: &DynamicBlock, node: i32) -> bool {
|
||||
let mut any = false;
|
||||
for a in &m.actions {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if rows.is_empty() || !columns.iter().any(|c| c.lookup && c.node == node) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if !columns
|
||||
.iter()
|
||||
.all(|c| matches!(c.property.to_ascii_lowercase().as_str(), "lookupstring" | "visibilitystate" | "updateddistance" | "distance"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
+111
-1
@@ -103,6 +103,11 @@ pub struct Common {
|
||||
/// The annotation scales an annotative object supports (empty: not annotative).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub anno_scales: Vec<crate::AnnoScale>,
|
||||
/// Other applications' extended data (xdata) as read from the drawing file: whole
|
||||
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
|
||||
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub xdata: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Default for Common {
|
||||
@@ -119,6 +124,7 @@ impl Default for Common {
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
anno_scales: Vec::new(),
|
||||
xdata: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +349,98 @@ pub struct Attrib {
|
||||
/// An attribute's own properties; each one left unset follows the attribute definition.
|
||||
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
|
||||
pub props: AttribProps,
|
||||
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
|
||||
/// multiline text. `text.value` holds the plain text, which value edits change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multiline: Option<MultilineAttrib>,
|
||||
/// Its other modes: Verify, Preset and Lock position.
|
||||
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
|
||||
pub modes: AttribModes,
|
||||
}
|
||||
|
||||
/// An attribute's modes beyond invisible and constant.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttribModes {
|
||||
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
|
||||
#[serde(default)]
|
||||
pub verify: bool,
|
||||
/// Preset: the default value is used without asking (DXF flag 8).
|
||||
#[serde(default)]
|
||||
pub preset: bool,
|
||||
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
|
||||
/// 280 after the flags). A reference's attributes take it from their definitions.
|
||||
#[serde(default)]
|
||||
pub lock_position: bool,
|
||||
}
|
||||
|
||||
impl AttribModes {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
|
||||
/// only Lock position there.
|
||||
pub fn for_reference(self) -> Self {
|
||||
AttribModes { lock_position: self.lock_position, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline text of a multiline attribute.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultilineAttrib {
|
||||
pub mtext: MText,
|
||||
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
|
||||
/// since, and its value replaces the contents.
|
||||
pub read_as: String,
|
||||
}
|
||||
|
||||
impl MultilineAttrib {
|
||||
/// This text placed by `m` (a block reference's transform, for its attribute made from a
|
||||
/// definition).
|
||||
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
|
||||
let mut k = EntityKind::MText(self.mtext.clone());
|
||||
k.transform(m);
|
||||
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
|
||||
MultilineAttrib { mtext, read_as: self.read_as.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Attrib {
|
||||
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
|
||||
pub fn transform_multiline(&mut self, m: &Mat3) {
|
||||
if let Some(ml) = &mut self.multiline {
|
||||
*ml = ml.transformed(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
|
||||
/// replaced by the value when the value was edited since it was read.
|
||||
pub fn multiline_text(&self) -> Option<MText> {
|
||||
let m = self.multiline.as_ref()?;
|
||||
let mut t = m.mtext.clone();
|
||||
if self.text.value != m.read_as {
|
||||
t.contents = mtext_literal(&self.text.value);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
|
||||
/// paragraph breaks.
|
||||
pub fn mtext_literal(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'{' => out.push_str("\\{"),
|
||||
'}' => out.push_str("\\}"),
|
||||
'\n' => out.push_str("\\P"),
|
||||
'\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
|
||||
@@ -825,6 +923,10 @@ pub struct TableCell {
|
||||
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub formula: Option<String>,
|
||||
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
|
||||
/// `None`: its cell style's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_height: Option<f64>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1155,7 +1257,10 @@ impl EntityKind {
|
||||
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
|
||||
}
|
||||
EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
|
||||
EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
|
||||
EntityKind::AttDef(a) => {
|
||||
transform_text(&mut a.text, m, s, rot, mirror);
|
||||
a.transform_multiline(m);
|
||||
}
|
||||
EntityKind::MText(t) => {
|
||||
t3(&mut t.insert);
|
||||
t.height *= s;
|
||||
@@ -1177,6 +1282,7 @@ impl EntityKind {
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
a.transform_multiline(m);
|
||||
}
|
||||
i.col_spacing *= s;
|
||||
i.row_spacing *= s;
|
||||
@@ -1206,6 +1312,10 @@ impl EntityKind {
|
||||
if let Some(t) = &mut ml.text {
|
||||
t3(&mut t.insert);
|
||||
t.height *= s;
|
||||
t.width *= s;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(s);
|
||||
}
|
||||
}
|
||||
if let Some(b) = &mut ml.block {
|
||||
// Transformed exactly like a block reference.
|
||||
|
||||
+39
-22
@@ -88,10 +88,12 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
/// by block name: every further reference to a block reuses them instead of expanding the block
|
||||
/// again, so nested or self-referencing blocks cost one pass over each definition instead of one
|
||||
/// per reference (which grows exponentially with the nesting depth).
|
||||
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
|
||||
/// by block name and depth: every further reference to a block at that depth reuses them instead of
|
||||
/// expanding the block again, so nested or self-referencing blocks cost at most one pass over each
|
||||
/// definition per depth instead of one per reference (which grows exponentially with the nesting
|
||||
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
|
||||
/// bounds must not stand in for a shallower reference to the same block (#706).
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Table(t) if t.is_broken() => t.bounds(),
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
@@ -106,28 +108,34 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
|
||||
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
|
||||
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
|
||||
// insertion point. Attributes below still count.
|
||||
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let inner = match blocks.get(&blk.name) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
let mut b = Bounds2::EMPTY;
|
||||
if let Some(blk) = d.block(&ins.block) {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
}
|
||||
blocks.insert(blk.name.clone(), inner);
|
||||
inner
|
||||
};
|
||||
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
|
||||
if ins.cols > 1 || ins.rows > 1 {
|
||||
let off =
|
||||
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
}
|
||||
};
|
||||
let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
|
||||
if ins.cols > 1 || ins.rows > 1 {
|
||||
let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible {
|
||||
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
@@ -309,7 +317,16 @@ mod tests {
|
||||
#[test]
|
||||
fn right_justified_attdef_is_left_of_alignment_point() {
|
||||
let text = text(HAlign::Right, VAlign::Baseline);
|
||||
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let a = crate::Attrib {
|
||||
tag: "HELLO".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
};
|
||||
let b = bounds(EntityKind::AttDef(a));
|
||||
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ pub struct ForeignExtra {
|
||||
pub xdict: Option<Handle>,
|
||||
/// Objects of [`Foreign::objects`] that react to it (persistent reactors).
|
||||
pub reactors: Vec<Handle>,
|
||||
/// Other applications' extended data: groups from `1001` on.
|
||||
/// Other applications' extended data of a table record or block: groups from `1001` on. An
|
||||
/// entity's is part of the entity ([`crate::Common::xdata`]), so it follows the entity's copies.
|
||||
pub xdata: Vec<RawTag>,
|
||||
/// The colour book colour (group 430) and the true colour (group 420) it was read with: it is
|
||||
/// written back while the colour is unchanged.
|
||||
@@ -65,6 +66,11 @@ pub struct Foreign {
|
||||
pub records: BTreeMap<ForeignKey, ForeignExtra>,
|
||||
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
|
||||
pub targets: BTreeMap<Handle, ForeignKey>,
|
||||
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
|
||||
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
|
||||
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
|
||||
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
|
||||
pub data_store: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
@@ -75,5 +81,11 @@ impl Foreign {
|
||||
&& self.records.is_empty()
|
||||
&& self.classes.is_empty()
|
||||
&& self.targets.is_empty()
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
/// The entities the data store has records for (their 320 handles).
|
||||
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
|
||||
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -15,6 +15,7 @@ mod header;
|
||||
pub mod library;
|
||||
mod plotstyle;
|
||||
mod plotter;
|
||||
mod refedit;
|
||||
mod store;
|
||||
mod tables;
|
||||
|
||||
@@ -30,10 +31,12 @@ pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
pub use plotstyle::*;
|
||||
pub use plotter::*;
|
||||
pub use refedit::{RefEdit, WorkItem};
|
||||
pub use store::EntityStore;
|
||||
pub use tables::*;
|
||||
|
||||
/// File data CADCraft doesn't model, kept for saving (#528).
|
||||
pub mod dynblock;
|
||||
pub mod foreign;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -59,6 +62,26 @@ pub enum Space {
|
||||
Paper(String),
|
||||
}
|
||||
|
||||
impl Space {
|
||||
/// Whether this is a layout's paper space.
|
||||
pub fn is_paper(&self) -> bool {
|
||||
matches!(self, Space::Paper(_))
|
||||
}
|
||||
|
||||
/// The layout whose paper space this is; `None` for model space.
|
||||
pub fn layout(&self) -> Option<&str> {
|
||||
match self {
|
||||
Space::Paper(n) => Some(n),
|
||||
Space::Model => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab name (CTAB): `Model`, or the layout's name.
|
||||
pub fn tab_name(&self) -> &str {
|
||||
self.layout().unwrap_or("Model")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Drawing {
|
||||
pub header: Header,
|
||||
@@ -95,6 +118,15 @@ pub struct Drawing {
|
||||
/// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file:
|
||||
/// a drawing opens with everything shown again.
|
||||
pub hidden_objects: BTreeSet<Handle>,
|
||||
/// What reading the file left out (a DWG's object records the reader couldn't decode), told
|
||||
/// to the user when the drawing opens. Never written to a file.
|
||||
pub read_notes: Vec<String>,
|
||||
/// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are
|
||||
/// written from [`Drawing::committed`].
|
||||
pub refedit: Option<RefEdit>,
|
||||
/// Records of the file this drawing was read from that the reader could not place (a damaged
|
||||
/// file), counted by the section they came from. Never written to a file.
|
||||
pub skipped_records: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Default for Drawing {
|
||||
@@ -110,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
|
||||
}
|
||||
@@ -148,6 +182,9 @@ impl Drawing {
|
||||
scales: imperial_scales(),
|
||||
handseed: 0x100,
|
||||
hidden_objects: BTreeSet::new(),
|
||||
read_notes: Vec::new(),
|
||||
refedit: None,
|
||||
skipped_records: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-21
@@ -153,9 +153,11 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
|
||||
name: "BRICK",
|
||||
description: "Brick or masonry-type surface",
|
||||
lines: vec![
|
||||
// Running bond: courses 0.25 high, bricks 0.5 long. Each vertical family draws the
|
||||
// joints of every other course, 0.5 apart; the second is offset half a brick.
|
||||
pl(0.0, (0.0, 0.0), (0.0, 0.25), &[]),
|
||||
pl(90.0, (0.0, 0.0), (0.25, 0.25), &[0.25, -0.25]),
|
||||
pl(90.0, (0.25, 0.0), (0.25, 0.25), &[-0.25, 0.25]),
|
||||
pl(90.0, (0.0, 0.0), (0.0, 0.5), &[0.25, -0.25]),
|
||||
pl(90.0, (0.25, 0.0), (0.0, 0.5), &[-0.25, 0.25]),
|
||||
],
|
||||
},
|
||||
HatchPattern {
|
||||
@@ -203,28 +205,73 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
|
||||
description: "Staircase effect",
|
||||
lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125]), pl(90.0, (0.125, 0.0), (0.125, 0.125), &[0.125, -0.125])],
|
||||
},
|
||||
HatchPattern {
|
||||
name: "AR-CONC",
|
||||
description: "Random dot and stone pattern",
|
||||
lines: vec![
|
||||
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
|
||||
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
|
||||
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
|
||||
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
|
||||
],
|
||||
},
|
||||
HatchPattern {
|
||||
name: "AR-SAND",
|
||||
description: "Random dot pattern",
|
||||
lines: vec![
|
||||
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
|
||||
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
|
||||
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
|
||||
],
|
||||
},
|
||||
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
|
||||
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
|
||||
scaled_pattern(
|
||||
HatchPattern {
|
||||
name: "AR-CONC",
|
||||
description: "Random dot and stone pattern",
|
||||
lines: vec![
|
||||
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
|
||||
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
|
||||
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
|
||||
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
|
||||
],
|
||||
},
|
||||
16.0,
|
||||
),
|
||||
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
|
||||
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
|
||||
scaled_pattern(
|
||||
HatchPattern {
|
||||
name: "AR-SAND",
|
||||
description: "Random dot pattern",
|
||||
lines: vec![
|
||||
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
|
||||
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
|
||||
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
|
||||
],
|
||||
},
|
||||
16.0,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn pattern(name: &str) -> Option<HatchPattern> {
|
||||
standard_patterns().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The library pattern `name` as drawn in `d`. In a metric drawing (`MEASUREMENT` 1) every
|
||||
/// length is multiplied by 25.4, as CAD programs use ISO-scaled pattern definitions there (as
|
||||
/// for linetypes, see [`standard_linetypes_for`]): a hatch at scale 1 measures in millimetres
|
||||
/// what it measures in inches in an imperial drawing.
|
||||
pub fn pattern_for(d: &Drawing, name: &str) -> Option<HatchPattern> {
|
||||
let p = pattern(name)?;
|
||||
Some(if d.header.i64("MEASUREMENT", 0) == 1 { scaled_pattern(p, 25.4) } else { p })
|
||||
}
|
||||
|
||||
/// `p` with every length (origins, offsets, dashes) multiplied by `k`.
|
||||
fn scaled_pattern(mut p: HatchPattern, k: f64) -> HatchPattern {
|
||||
for l in &mut p.lines {
|
||||
l.origin = (l.origin.0 * k, l.origin.1 * k);
|
||||
l.delta = (l.delta.0 * k, l.delta.1 * k);
|
||||
for d in &mut l.dashes {
|
||||
*d *= k;
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metric_drawings_draw_iso_scaled_patterns() {
|
||||
let imp = pattern_for(&Drawing::new_imperial(), "ANSI31").map(|p| p.lines[0].delta.1);
|
||||
let met = pattern_for(&Drawing::new_metric(), "ansi31").map(|p| p.lines[0].delta.1);
|
||||
assert_eq!(imp, Some(0.125));
|
||||
assert!(met.is_some_and(|m| (m - 0.125 * 25.4).abs() < 1e-12), "{met:?}");
|
||||
assert!(pattern_for(&Drawing::new_metric(), "NOPE").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! In-place editing of a block reference (REFEDIT / REFSET / REFCLOSE): the state kept with the
|
||||
//! drawing while it lasts.
|
||||
//!
|
||||
//! The reference being edited is hidden and the block's objects stand in the drawing as ordinary
|
||||
//! objects (the *working set*, transformed to where the reference shows them), the rest of the
|
||||
//! block as faded copies (*ghosts*) that cannot be edited. REFCLOSE writes the working set back
|
||||
//! into the block definition. The state lives in the [`Drawing`] itself, so undo and redo move
|
||||
//! the working copies and the state together and can never leave one without the other.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use cadcraft_geom::Mat3;
|
||||
|
||||
use crate::{Drawing, Entity, Handle, Space};
|
||||
|
||||
/// One object of the working set.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WorkItem {
|
||||
/// The object in the drawing (a copy of the block object, or a drawing object added to the set).
|
||||
pub copy: Handle,
|
||||
/// The block object it was copied from; `None` for an object added from the drawing.
|
||||
pub source: Option<Handle>,
|
||||
}
|
||||
|
||||
/// An active in-place edit of a block reference. Never written to a file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RefEdit {
|
||||
/// The block being edited.
|
||||
pub block: String,
|
||||
/// The reference that was picked (hidden while editing).
|
||||
pub insert: Handle,
|
||||
/// Whether the reference was visible before it was hidden.
|
||||
pub insert_was_visible: bool,
|
||||
/// The space the reference is in.
|
||||
pub space: Space,
|
||||
/// Block coordinates to drawing coordinates, through the picked reference.
|
||||
pub to_world: Mat3,
|
||||
/// The working set.
|
||||
pub items: Vec<WorkItem>,
|
||||
/// The rest of the block as faded copies: (copy, block object).
|
||||
pub ghosts: Vec<(Handle, Handle)>,
|
||||
/// Block objects taken out of the working set with their edits (block coordinates, the block
|
||||
/// object's handle).
|
||||
pub released: Vec<Entity>,
|
||||
}
|
||||
|
||||
impl RefEdit {
|
||||
/// Whether `h` is a working set object.
|
||||
pub fn is_working(&self, h: Handle) -> bool {
|
||||
self.items.iter().any(|i| i.copy == h)
|
||||
}
|
||||
/// Whether `h` is something the edit manages (working set, ghost, the hidden reference).
|
||||
pub fn owns(&self, h: Handle) -> bool {
|
||||
h == self.insert || self.is_working(h) || self.ghosts.iter().any(|(g, _)| *g == h)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The drawing as REFCLOSE Discard would leave it: the working copies and ghosts gone, the
|
||||
/// reference shown, the block untouched. Borrowed when no edit is active. Files are written
|
||||
/// from this, so a saved or autosaved drawing never holds half an edit.
|
||||
pub fn committed(&self) -> Cow<'_, Drawing> {
|
||||
let Some(r) = &self.refedit else { return Cow::Borrowed(self) };
|
||||
let mut d = self.clone();
|
||||
d.end_refedit_discard(r);
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Remove the edit's working copies and ghosts, show the reference again and clear the state.
|
||||
pub fn end_refedit_discard(&mut self, r: &RefEdit) {
|
||||
for i in &r.items {
|
||||
if i.source.is_some() {
|
||||
self.remove_entity(i.copy);
|
||||
}
|
||||
}
|
||||
for (g, _) in &r.ghosts {
|
||||
self.remove_entity(*g);
|
||||
}
|
||||
let vis = r.insert_was_visible;
|
||||
let _ = self.modify_entity(r.insert, |e| e.common.visible = vis);
|
||||
self.refedit = None;
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -747,6 +761,12 @@ pub struct PageSetup {
|
||||
/// Draw the layout on screen (and in its PNG/SVG exports) with its plot style table
|
||||
/// applied ("Display plot styles"; DXF plot layout flag 2).
|
||||
pub show_plot_styles: bool,
|
||||
/// 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 {
|
||||
@@ -765,6 +785,8 @@ impl Default for PageSetup {
|
||||
center: false,
|
||||
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();
|
||||
@@ -79,6 +96,59 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_attributes_of_a_missing_block_still_count() {
|
||||
// The renderer draws the attributes of a reference whose definition is missing, so they are
|
||||
// extents and selection targets, as with an empty definition. The insertion point still isn't.
|
||||
let attrib = |invisible: bool| Attrib {
|
||||
tag: "TAG".into(),
|
||||
text: Text {
|
||||
insert: Vec3::new(1000.0, 1000.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 10.0,
|
||||
value: "hello".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
};
|
||||
let ins = |block: &str, attribs: Vec<Attrib>| {
|
||||
Entity::new(
|
||||
Handle(1),
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs,
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
|
||||
let missing = entity_bounds(&d, &ins("Missing", vec![attrib(false)]), 0);
|
||||
let empty = entity_bounds(&d, &ins("Empty", vec![attrib(false)]), 0);
|
||||
assert!(!missing.is_empty());
|
||||
assert_eq!((missing.min, missing.max), (empty.min, empty.max));
|
||||
assert_eq!(missing.min, Vec2::new(1000.0, 1000.0));
|
||||
// Without a visible attribute there is nothing to measure.
|
||||
assert!(entity_bounds(&d, &ins("Missing", vec![]), 0).is_empty());
|
||||
assert!(entity_bounds(&d, &ins("Missing", vec![attrib(true)]), 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_arc_mirror_keeps_shape() {
|
||||
let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 });
|
||||
@@ -269,6 +339,38 @@ fn extents_pair(d: &Drawing) -> (Vec2, Vec2) {
|
||||
(b.min, b.max)
|
||||
}
|
||||
|
||||
/// #706: block A reaches B once through a chain of 14 blocks (B at depth 15, its line's block E at
|
||||
/// 16, past the limit) and once directly (B at depth 1). `children` picks which of the two A holds.
|
||||
fn deep_and_shallow(children: &[&str]) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut add = |name: &str, kids: Vec<EntityKind>| {
|
||||
let mut blk = Block::new(name);
|
||||
for (i, k) in kids.into_iter().enumerate() {
|
||||
blk.entities.push(Entity::new(Handle(i as u64 + 1), k));
|
||||
}
|
||||
d.blocks.insert(name.into(), std::sync::Arc::new(blk));
|
||||
};
|
||||
add("E", vec![line((1000.0, 1000.0), (2000.0, 1000.0))]);
|
||||
add("B", vec![plain_insert("E")]);
|
||||
for i in 1..=14 {
|
||||
let next = if i == 14 { "B".to_string() } else { format!("C{}", i + 1) };
|
||||
add(&format!("C{i}"), vec![plain_insert(&next)]);
|
||||
}
|
||||
add("A", children.iter().map(|c| plain_insert(c)).collect());
|
||||
d.add(&Space::Model, Common::default(), plain_insert("A")).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_block_visit_does_not_hide_a_shallow_reference() {
|
||||
let line = (Vec2::new(1000.0, 1000.0), Vec2::new(2000.0, 1000.0));
|
||||
// Control: past the limit, the deep path alone contributes nothing.
|
||||
assert!(deep_and_shallow(&["C1"]).extents(&Space::Model).is_empty());
|
||||
assert_eq!(extents_pair(&deep_and_shallow(&["B"])), line);
|
||||
assert_eq!(extents_pair(&deep_and_shallow(&["B", "C1"])), line);
|
||||
assert_eq!(extents_pair(&deep_and_shallow(&["C1", "B"])), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
|
||||
let mut far = far_line("0");
|
||||
@@ -354,6 +456,8 @@ fn attdef_in_block_is_ignored_in_insert_extents() {
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
}),
|
||||
);
|
||||
let d = drawing_with_block(attdef);
|
||||
@@ -466,3 +570,14 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
dim(&mut d, far_line("0"));
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_names_its_tab() {
|
||||
assert!(!Space::Model.is_paper());
|
||||
assert_eq!(Space::Model.layout(), None);
|
||||
assert_eq!(Space::Model.tab_name(), "Model");
|
||||
let p = Space::Paper("Sheet A".into());
|
||||
assert!(p.is_paper());
|
||||
assert_eq!(p.layout(), Some("Sheet A"));
|
||||
assert_eq!(p.tab_name(), "Sheet A");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! `Sub::parse` accepts every spelling its documentation lists (#710).
|
||||
|
||||
use cadcraft_doc::Sub;
|
||||
|
||||
#[test]
|
||||
fn short_segment_name_matches_the_documented_forms() {
|
||||
for spelling in ["seg2", "segment2", "s2"] {
|
||||
assert_eq!(Sub::parse(spelling), Some(Sub::Segment(2)), "spelling: {spelling}");
|
||||
}
|
||||
for spelling in ["s", "start"] {
|
||||
assert_eq!(Sub::parse(spelling), Some(Sub::Start), "spelling: {spelling}");
|
||||
}
|
||||
assert_eq!(Sub::parse("unknown"), None);
|
||||
}
|
||||
@@ -10,4 +10,4 @@ workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.3"
|
||||
acadrust = "0.6.4"
|
||||
+506
-6
@@ -61,14 +61,39 @@ mod native {
|
||||
/// guard: the input size before the DWG reader runs, the DXF size while it is written, so a
|
||||
/// runaway conversion stops at the limit instead of filling memory first.
|
||||
pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result<Vec<u8>, String> {
|
||||
dwg_to_dxf_noted(bytes, limits).map(|(dxf, _)| dxf)
|
||||
}
|
||||
|
||||
/// [`dwg_to_dxf_with`], also returning notes on what the read left out. The reader runs in
|
||||
/// failsafe mode: an object record it can't decode is skipped instead of failing the whole
|
||||
/// file, and the notes say how many were skipped and of which types, so a partial read never
|
||||
/// passes for a complete one.
|
||||
pub fn dwg_to_dxf_noted(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
|
||||
}
|
||||
let mut doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
|
||||
.map_err(|_| "the DWG reader failed on this file".to_string())?
|
||||
.map_err(|e| format!("DWG: {e}"))?;
|
||||
let outcome = std::panic::catch_unwind(|| {
|
||||
acadrust::DwgReader::from_stream_with_options(Cursor::new(bytes), acadrust::DwgReadOptions::failsafe()).read_with_stats()
|
||||
})
|
||||
.map_err(|_| "the DWG reader failed on this file".to_string())?
|
||||
.map_err(|e| format!("DWG: {e}"))?;
|
||||
// Failsafe skips records, not whole files: when the objects stream itself couldn't be
|
||||
// read (a damaged header or section map, or not a DWG at all), nothing was read.
|
||||
if !outcome.stats.record_stream_read {
|
||||
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 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())?;
|
||||
@@ -81,7 +106,111 @@ mod native {
|
||||
));
|
||||
}
|
||||
written.map_err(|e| format!("DWG→DXF: {e}"))?;
|
||||
Ok(add_header_vars(out.buf, &doc.header))
|
||||
Ok((add_header_vars(out.buf, &doc.header), notes))
|
||||
}
|
||||
|
||||
/// Most record types named in the skipped-records note.
|
||||
const NOTE_TYPES: usize = 6;
|
||||
|
||||
/// The note on object records a failsafe read skipped: how many, and how many of each type
|
||||
/// (most first), or nothing when the read was complete.
|
||||
pub(crate) fn read_notes(stats: &acadrust::ReadStats) -> Vec<String> {
|
||||
let skipped = stats.skipped_source_records;
|
||||
if skipped == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let label = |t: Option<&str>| match t.map(str::parse::<i16>) {
|
||||
Some(Ok(code)) => type_name(code).map_or_else(|| format!("type {code}"), str::to_string),
|
||||
Some(Err(_)) => t.unwrap_or_default().to_string(),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
let mut by_type: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
for d in stats.diagnostics.iter().filter(|d| d.record_handle.is_some()) {
|
||||
*by_type.entry(label(d.record_type.as_deref())).or_default() += 1;
|
||||
}
|
||||
let mut types: Vec<(String, usize)> = by_type.into_iter().collect();
|
||||
types.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let mut list: Vec<String> = types.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t} × {n}")).collect();
|
||||
if types.len() > NOTE_TYPES {
|
||||
list.push(format!("{} more types", types.len() - NOTE_TYPES));
|
||||
}
|
||||
let of = if stats.source_records > 0 { format!(" of {}", stats.source_records) } else { String::new() };
|
||||
let records = if skipped == 1 && of.is_empty() { "object record" } else { "object records" };
|
||||
let (were, them) = if skipped == 1 { ("was", "it") } else { ("were", "them") };
|
||||
let which = if list.is_empty() { String::new() } else { format!(" ({})", list.join(", ")) };
|
||||
vec![format!(
|
||||
"DWG: {skipped}{of} {records} couldn't be read and {were} left out{which}; \
|
||||
saving over the original file would lose {them}"
|
||||
)]
|
||||
}
|
||||
|
||||
/// The DXF name of a DWG object type code with a fixed number (classes, 500 and up, are
|
||||
/// numbered per file).
|
||||
fn type_name(code: i16) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
1 => "TEXT",
|
||||
2 => "ATTRIB",
|
||||
3 => "ATTDEF",
|
||||
4 => "BLOCK",
|
||||
5 => "ENDBLK",
|
||||
6 => "SEQEND",
|
||||
7 => "INSERT",
|
||||
8 => "MINSERT",
|
||||
10..=14 => "VERTEX",
|
||||
15 | 16 | 29 | 30 => "POLYLINE",
|
||||
17 => "ARC",
|
||||
18 => "CIRCLE",
|
||||
19 => "LINE",
|
||||
20..=26 => "DIMENSION",
|
||||
27 => "POINT",
|
||||
28 => "3DFACE",
|
||||
31 => "SOLID",
|
||||
32 => "TRACE",
|
||||
33 => "SHAPE",
|
||||
34 => "VIEWPORT",
|
||||
35 => "ELLIPSE",
|
||||
36 => "SPLINE",
|
||||
37 => "REGION",
|
||||
38 => "3DSOLID",
|
||||
39 => "BODY",
|
||||
40 => "RAY",
|
||||
41 => "XLINE",
|
||||
42 => "DICTIONARY",
|
||||
43 => "OLEFRAME",
|
||||
44 => "MTEXT",
|
||||
45 => "LEADER",
|
||||
46 => "TOLERANCE",
|
||||
47 => "MLINE",
|
||||
48 => "BLOCK_CONTROL",
|
||||
49 => "BLOCK_RECORD",
|
||||
50 => "LAYER_CONTROL",
|
||||
51 => "LAYER",
|
||||
52 => "STYLE_CONTROL",
|
||||
53 => "STYLE",
|
||||
56 => "LTYPE_CONTROL",
|
||||
57 => "LTYPE",
|
||||
60 => "VIEW_CONTROL",
|
||||
61 => "VIEW",
|
||||
62 => "UCS_CONTROL",
|
||||
63 => "UCS",
|
||||
64 => "VPORT_CONTROL",
|
||||
65 => "VPORT",
|
||||
66 => "APPID_CONTROL",
|
||||
67 => "APPID",
|
||||
68 => "DIMSTYLE_CONTROL",
|
||||
69 => "DIMSTYLE",
|
||||
70 => "VX_CONTROL",
|
||||
71 => "VX_TABLE_RECORD",
|
||||
72 => "GROUP",
|
||||
73 => "MLINESTYLE",
|
||||
74 => "OLE2FRAME",
|
||||
77 => "LWPOLYLINE",
|
||||
78 => "HATCH",
|
||||
79 => "XRECORD",
|
||||
80 => "ACDBPLACEHOLDER",
|
||||
82 => "LAYOUT",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's
|
||||
@@ -143,11 +272,127 @@ 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
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
|
||||
let version = doc.version;
|
||||
let unwritable: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Unknown(u)
|
||||
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
Some(u.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in &unwritable {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
let objects: Vec<acadrust::Handle> = doc
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
|
||||
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
Some(*h)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
unwritable.len() + objects.len()
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
/// the HEADER section: (name, group code, value).
|
||||
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
|
||||
[
|
||||
("$ANGBASE", 50, h.angle_base.to_string()),
|
||||
// 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()),
|
||||
@@ -225,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()))
|
||||
@@ -238,15 +507,44 @@ 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"))]
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
|
||||
/// The error for a DWG version CADCraft can't write.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
@@ -260,6 +558,10 @@ pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Err("DWG files can't be opened in the web build yet; save as DXF".into())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
Err("DWG files can't be opened in the web build yet; save as DXF".into())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
|
||||
Err("DWG files can't be opened in the web build yet; save as DXF".into())
|
||||
}
|
||||
@@ -288,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.
|
||||
|
||||
@@ -109,6 +109,9 @@ pub enum Job {
|
||||
struct Tracked {
|
||||
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
|
||||
written: Option<Weak<Drawing>>,
|
||||
/// The drawing outside the Block Editor as last autosaved (the same as `written` when the
|
||||
/// editor is closed): saving the editor's changes into the block changes only this.
|
||||
host: Option<Weak<Drawing>>,
|
||||
/// Its autosave file, once written (or adopted).
|
||||
file: Option<String>,
|
||||
/// When a change not yet autosaved was first seen (seconds).
|
||||
@@ -122,8 +125,9 @@ pub struct Autosaver {
|
||||
docs: HashMap<u64, Tracked>,
|
||||
/// Drawings whose autosave is being written right now.
|
||||
busy: HashSet<u64>,
|
||||
/// Writes in progress; each tells whether its file was written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec<(u64, std::thread::JoinHandle<()>)>,
|
||||
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -149,8 +153,9 @@ impl Autosaver {
|
||||
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
|
||||
/// once the drawing is saved or closed.
|
||||
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
|
||||
let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc));
|
||||
self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None });
|
||||
let st = s.docs.iter().find(|d| d.uid == uid);
|
||||
let (written, host) = (st.map(|d| Arc::downgrade(&d.doc)), st.map(|d| Arc::downgrade(d.drawing())));
|
||||
self.docs.insert(uid, Tracked { written, host, file: Some(path.to_string()), since: None });
|
||||
}
|
||||
|
||||
/// The autosave files this app has written or adopted.
|
||||
@@ -162,6 +167,12 @@ impl Autosaver {
|
||||
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
|
||||
/// files of drawings that were saved (no unsaved changes left) or closed.
|
||||
pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec<Job> {
|
||||
self.plan_with(s, now, minutes, dir, false)
|
||||
}
|
||||
|
||||
/// [`Autosaver::plan`], with `urgent` writing every drawing with changes not yet autosaved
|
||||
/// now instead of `minutes` after the change (autosave still off when `minutes` is 0).
|
||||
fn plan_with(&mut self, s: &Session, now: f64, minutes: u32, dir: &str, urgent: bool) -> Vec<Job> {
|
||||
let mut jobs = Vec::new();
|
||||
let live: HashSet<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let busy = &self.busy;
|
||||
@@ -187,13 +198,14 @@ impl Autosaver {
|
||||
*t = Tracked::default();
|
||||
continue;
|
||||
}
|
||||
let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc)));
|
||||
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
|
||||
let changed = !same(&t.written, &st.doc) || !same(&t.host, st.drawing());
|
||||
if !changed {
|
||||
t.since = None;
|
||||
continue;
|
||||
}
|
||||
let since = *t.since.get_or_insert(now);
|
||||
if minutes == 0 || dir.trim().is_empty() || now - since < f64::from(minutes) * 60.0 {
|
||||
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
|
||||
continue;
|
||||
}
|
||||
let path = t
|
||||
@@ -202,8 +214,10 @@ impl Autosaver {
|
||||
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
|
||||
t.file = Some(path.clone());
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.host = Some(Arc::downgrade(st.drawing()));
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() });
|
||||
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
@@ -335,14 +349,7 @@ mod native {
|
||||
/// Returns how long until the next autosave is due, so the host can wake up for it.
|
||||
pub fn tick(&mut self, s: &Session) -> Option<Duration> {
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let busy = &mut self.busy;
|
||||
self.running.retain(|(uid, h)| {
|
||||
let running = !h.is_finished();
|
||||
if !running {
|
||||
busy.remove(uid);
|
||||
}
|
||||
running
|
||||
});
|
||||
self.reap();
|
||||
let dir = savefilepath();
|
||||
let minutes = savetime();
|
||||
let jobs = self.plan(s, now, minutes, &dir);
|
||||
@@ -351,6 +358,40 @@ mod native {
|
||||
self.next_due(now, minutes).map(Duration::from_secs_f64)
|
||||
}
|
||||
|
||||
/// Autosave every drawing with changes not yet autosaved right away, without waiting for
|
||||
/// SAVETIME (the app may not survive what just happened, e.g. a GPU error). Returns how
|
||||
/// many drawings are being written.
|
||||
pub fn save_now(&mut self, s: &Session) -> usize {
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let jobs = self.plan_with(s, now, savetime(), &savefilepath(), true);
|
||||
let writes = jobs.iter().filter(|j| matches!(j, Job::Write { .. })).count();
|
||||
self.perform(jobs);
|
||||
// The lock file marks the files as this running app's (Drawing Recovery skips them).
|
||||
self.last_beat = None;
|
||||
self.heartbeat(&savefilepath(), now);
|
||||
writes
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change.
|
||||
pub fn reap(&mut self) {
|
||||
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
|
||||
self.running = running;
|
||||
for (uid, h) in done {
|
||||
self.busy.remove(&uid);
|
||||
if !h.join().unwrap_or(false) {
|
||||
self.unwritten(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten(&mut self, uid: u64) {
|
||||
if let Some(t) = self.docs.get_mut(&uid) {
|
||||
t.written = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
pub fn perform(&mut self, jobs: Vec<Job>) {
|
||||
for job in jobs {
|
||||
@@ -362,15 +403,24 @@ mod native {
|
||||
continue;
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
|
||||
Ok(()) => log::info!("autosaved {path}"),
|
||||
Err(e) => log::warn!("autosave {path}: {e}"),
|
||||
Ok(()) => {
|
||||
log::info!("autosaved {path}");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("autosave {path}: {e}");
|
||||
false
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(h) => {
|
||||
self.busy.insert(uid);
|
||||
self.running.push((uid, h));
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
Err(e) => {
|
||||
log::warn!("autosave: {e}");
|
||||
self.unwritten(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! The Block Editor's state (BEDIT). While it is open, the drawing's tab shows the block's objects
|
||||
//! as its model space, moved so that the block's base point is at the origin, and every command
|
||||
//! edits them as it would any drawing. The drawing itself waits, untouched, with its undo history,
|
||||
//! selection and views: BSAVE writes the objects back into the block definition (so every
|
||||
//! reference shows them), BCLOSE puts the drawing back. The commands are in
|
||||
//! `cmd::blocks::bedit`.
|
||||
//!
|
||||
//! Layer, style and block table changes made in the editor belong to the drawing too, as in
|
||||
//! AutoCAD: saving keeps the editor's tables and only puts back what is the drawing's own (its
|
||||
//! model and paper space objects, groups, constraints, tiled viewports and hidden objects).
|
||||
//!
|
||||
//! The editor can also open on the drawing itself ([`CURRENT_DRAWING`], AutoCAD's
|
||||
//! `<Current Drawing>`): a dynamic block's own drawing, whose model space holds the block's
|
||||
//! objects and authoring elements. Its objects are edited where they are; BSAVE keeps the
|
||||
//! changes as the drawing's, BCLOSE without saving puts the drawing back as it was.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore};
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
|
||||
use crate::{DocState, EngineError, Result, Snapshot};
|
||||
|
||||
/// The undo step a save into the block records in the drawing.
|
||||
const UNDO_LABEL: &str = "Block Editor";
|
||||
|
||||
/// The name the Block Editor gives the drawing itself (BEDIT `<Current Drawing>`).
|
||||
pub const CURRENT_DRAWING: &str = "<Current Drawing>";
|
||||
|
||||
/// Whether block `name` (or `*Model_Space`, a drawing's own) carries dynamic block data from
|
||||
/// another program: parameters and actions in the `ACAD_ENHANCEDBLOCK` dictionary of its
|
||||
/// BLOCK_RECORD table entry.
|
||||
pub fn has_dynamic_data(d: &Drawing, name: &str) -> bool {
|
||||
let key = ("BLOCK_RECORD".to_string(), name.to_ascii_uppercase());
|
||||
let Some(xdict) = d.foreign.records.get(&key).and_then(|x| x.xdict) else { return false };
|
||||
d.foreign.objects.iter().any(|o| o.handle == xdict && o.tags.iter().any(|t| t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK")))
|
||||
}
|
||||
|
||||
/// Whether drawing `d` has authoring elements: it is a dynamic block's own drawing, its model
|
||||
/// space carrying the block's parameters and actions (AutoCAD offers to open it in the Block
|
||||
/// Editor).
|
||||
pub fn has_authoring_elements(d: &Drawing) -> bool {
|
||||
has_dynamic_data(d, "*Model_Space")
|
||||
}
|
||||
|
||||
/// The Block Editor open in a drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockEdit {
|
||||
/// The block being edited, as the drawing names it.
|
||||
pub name: String,
|
||||
/// The drawing outside the editor: its drawing, undo history, selection and views, put back
|
||||
/// by BCLOSE. Its own `block_edit` is always `None`.
|
||||
pub host: Box<DocState>,
|
||||
/// The editor's drawing as last saved into the block; later changes are unsaved.
|
||||
pub saved: Arc<Drawing>,
|
||||
/// The block's objects as the drawing has them (block coordinates): objects left unchanged in
|
||||
/// the editor are saved back exactly as they were.
|
||||
original: EntityStore,
|
||||
/// The block's base point, at the origin in the editor.
|
||||
base: Vec2,
|
||||
/// The editor is open on the drawing itself ([`CURRENT_DRAWING`]).
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
/// Why a block can't be opened in the Block Editor, if it can't.
|
||||
pub fn refusal(d: &Drawing, name: &str) -> Option<String> {
|
||||
let Some(b) = d.block(name) else { return Some(format!("Block \"{name}\" not found.")) };
|
||||
if b.xref_path.is_some() {
|
||||
return Some(format!("\"{}\" is an external reference; open its drawing (XOPEN) to edit it.", b.name));
|
||||
}
|
||||
if b.anonymous || b.name.starts_with('*') {
|
||||
return Some(format!("\"{}\" is an anonymous block made by another object; it can't be edited in the Block Editor.", b.name));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `store` moved by `m`, objects in the same order with the same handles.
|
||||
fn moved(store: &EntityStore, m: &Mat3) -> EntityStore {
|
||||
let mut out = EntityStore::new();
|
||||
for e in store.iter() {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(m);
|
||||
out.push(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether any object of `store` refers to block `name`, directly or through nested blocks of `d`.
|
||||
fn refers_to(d: &Drawing, store: &EntityStore, name: &str) -> bool {
|
||||
let mut todo: Vec<String> = store.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
if b.eq_ignore_ascii_case(name) {
|
||||
return true;
|
||||
}
|
||||
if seen.insert(b.to_ascii_uppercase())
|
||||
&& let Some(blk) = d.block(&b)
|
||||
{
|
||||
todo.extend(blk.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl BlockEdit {
|
||||
/// The editor's objects in block coordinates, ready to be the block's: unchanged objects are
|
||||
/// the block's own, the others are moved back from the editor's origin to the base point.
|
||||
fn block_objects(&self, editor: &Drawing) -> EntityStore {
|
||||
if self.base == Vec2::ZERO {
|
||||
return editor.model.clone();
|
||||
}
|
||||
let (to_editor, back) = (Mat3::translate(-self.base), Mat3::translate(self.base));
|
||||
let mut out = EntityStore::new();
|
||||
for e in editor.model.iter() {
|
||||
let unchanged = self.original.get(e.handle).filter(|o| {
|
||||
let mut shown = (***o).clone();
|
||||
shown.kind.transform(&to_editor);
|
||||
shown == **e
|
||||
});
|
||||
match unchanged {
|
||||
Some(o) => out.push((**o).clone()),
|
||||
None => {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&back);
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The drawing with the editor's objects saved into block `name` (the edited block, or a new
|
||||
/// name for BSAVEAS), from `editor`, the editor's drawing.
|
||||
pub fn compose(&self, editor: &Drawing, name: &str) -> Result<Drawing> {
|
||||
// The drawing itself: the editor's drawing is the drawing.
|
||||
if self.current {
|
||||
if !name.eq_ignore_ascii_case(CURRENT_DRAWING) {
|
||||
return Err(EngineError::Other("The current drawing can't be saved as a block from the Block Editor; use WBLOCK or BLOCK.".into()));
|
||||
}
|
||||
return Ok(editor.clone());
|
||||
}
|
||||
if refers_to(editor, &editor.model, name) {
|
||||
return Err(EngineError::Other(format!("Block \"{name}\" can't contain a reference to itself.")));
|
||||
}
|
||||
let host = &self.host.doc;
|
||||
let mut d = editor.clone();
|
||||
d.model = host.model.clone();
|
||||
d.layouts = host.layouts.clone();
|
||||
d.groups = host.groups.clone();
|
||||
d.constraints = host.constraints.clone();
|
||||
d.vports = host.vports.clone();
|
||||
d.hidden_objects = host.hidden_objects.clone();
|
||||
let entities = self.block_objects(editor);
|
||||
// The edited block's properties (the editor's definition, else the drawing's): the objects
|
||||
// are placed for its base point, also when saved under another name (BSAVEAS).
|
||||
let template = editor.block(&self.name).or_else(|| host.block(&self.name));
|
||||
let mut b = match template {
|
||||
Some(b) => (**b).clone(),
|
||||
None => cadcraft_doc::Block::new(name),
|
||||
};
|
||||
// Block names are case-insensitive: an existing block keeps its name's spelling.
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
if !b.name.eq_ignore_ascii_case(name) {
|
||||
b.name = key.clone();
|
||||
}
|
||||
b.base.x = self.base.x;
|
||||
b.base.y = self.base.y;
|
||||
b.entities = entities;
|
||||
d.blocks.insert(key, Arc::new(b));
|
||||
Ok(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
|
||||
pub fn drawing(&self) -> &Arc<Drawing> {
|
||||
match &self.block_edit {
|
||||
Some(be) => &be.host.doc,
|
||||
None => &self.doc,
|
||||
}
|
||||
}
|
||||
/// [`DocState::drawing`], to change it (a save's file version).
|
||||
pub fn drawing_arc_mut(&mut self) -> &mut Arc<Drawing> {
|
||||
match &mut self.block_edit {
|
||||
Some(be) => &mut be.host.doc,
|
||||
None => &mut self.doc,
|
||||
}
|
||||
}
|
||||
/// The block the Block Editor is editing, when it is open.
|
||||
pub fn block_editor(&self) -> Option<&str> {
|
||||
self.block_edit.as_ref().map(|b| b.name.as_str())
|
||||
}
|
||||
/// The Block Editor has changes not yet saved into the block.
|
||||
pub fn block_unsaved(&self) -> bool {
|
||||
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
|
||||
}
|
||||
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
|
||||
/// block, so a crash doesn't lose them.
|
||||
pub fn recovery_drawing(&self) -> Arc<Drawing> {
|
||||
match &self.block_edit {
|
||||
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
|
||||
Some(be) => be.host.doc.clone(),
|
||||
None => self.doc.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` (BEDIT).
|
||||
/// With `create`, a name no block has yet starts a new, empty block, which BSAVE creates.
|
||||
pub fn open_block_editor(&mut self, name: &str, create: bool) -> Result<()> {
|
||||
if let Some(open) = self.block_editor() {
|
||||
return Err(EngineError::Other(format!("The Block Editor is already open on \"{open}\"; close it first (BCLOSE).")));
|
||||
}
|
||||
if name.trim().eq_ignore_ascii_case(CURRENT_DRAWING) {
|
||||
let host = Box::new(self.clone());
|
||||
let editor = self.doc.clone();
|
||||
self.enter_block_editor(BlockEdit {
|
||||
name: CURRENT_DRAWING.into(),
|
||||
host,
|
||||
saved: editor,
|
||||
original: EntityStore::new(),
|
||||
base: Vec2::ZERO,
|
||||
current: true,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let block = match self.doc.block(name) {
|
||||
None if create => Arc::new(cadcraft_doc::Block::new(name)),
|
||||
_ => {
|
||||
if let Some(why) = refusal(&self.doc, name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.doc.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?
|
||||
}
|
||||
};
|
||||
let base = block.base.xy();
|
||||
let mut d = (*self.doc).clone();
|
||||
d.model = if base == Vec2::ZERO { block.entities.clone() } else { moved(&block.entities, &Mat3::translate(-base)) };
|
||||
// The drawing's own groups, constraints and hidden objects stay with it.
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
d.hidden_objects.clear();
|
||||
let host = Box::new(self.clone());
|
||||
let editor = Arc::new(d);
|
||||
self.doc = editor.clone();
|
||||
self.enter_block_editor(BlockEdit { name: block.name.clone(), host, saved: editor, original: block.entities.clone(), base, current: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
|
||||
/// undo history, nothing selected, model space.
|
||||
fn enter_block_editor(&mut self, be: BlockEdit) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.undo_ctl = Default::default();
|
||||
self.selection.clear();
|
||||
self.previous_selection.clear();
|
||||
self.space = cadcraft_doc::Space::Model;
|
||||
self.mspace = None;
|
||||
self.tiles = Default::default();
|
||||
self.views.clear();
|
||||
self.view_history.clear();
|
||||
self.block_edit = Some(be);
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Save the Block Editor's objects into the block (BSAVE), or into a new block `as_name`
|
||||
/// (BSAVEAS), which the editor then edits. One undo step in the drawing.
|
||||
pub fn save_block_editor(&mut self, as_name: Option<&str>) -> Result<()> {
|
||||
let Some(be) = &self.block_edit else { return Err(EngineError::Other("The Block Editor is not open.".into())) };
|
||||
let name = as_name.unwrap_or(&be.name).to_string();
|
||||
let d = be.compose(&self.doc, &name)?;
|
||||
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
|
||||
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
|
||||
let editor = self.doc.clone();
|
||||
let Some(be) = &mut self.block_edit else { return Ok(()) };
|
||||
let before = be.host.doc.clone();
|
||||
let selection = be.host.selection.clone();
|
||||
be.host.push_undo(Snapshot { label: UNDO_LABEL.into(), doc: before, selection });
|
||||
be.host.doc = Arc::new(d);
|
||||
be.host.revision += 1;
|
||||
be.original = original;
|
||||
be.saved = editor;
|
||||
be.name = name;
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the Block Editor (BCLOSE), saving its changes into the block first when `save`.
|
||||
pub fn close_block_editor(&mut self, save: bool) -> Result<()> {
|
||||
if self.block_edit.is_none() {
|
||||
return Err(EngineError::Other("The Block Editor is not open.".into()));
|
||||
}
|
||||
if save && self.block_unsaved() {
|
||||
self.save_block_editor(None)?;
|
||||
}
|
||||
let Some(be) = self.block_edit.take() else { return Ok(()) };
|
||||
let host = *be.host;
|
||||
self.doc = host.doc;
|
||||
self.undo = host.undo;
|
||||
self.redo = host.redo;
|
||||
self.undo_ctl = host.undo_ctl;
|
||||
// The drawing comes back with nothing selected; Previous is what was selected before.
|
||||
self.selection.clear();
|
||||
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
|
||||
self.space = host.space;
|
||||
self.mspace = host.mspace;
|
||||
self.tiles = host.tiles;
|
||||
self.views = host.views;
|
||||
self.view_history = host.view_history;
|
||||
self.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -447,6 +447,9 @@ impl Interactive for ArrayM {
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
|
||||
}
|
||||
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
|
||||
self.inner.as_ref().and_then(|m| m.drag(s, cursor))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ARRAYRECT ----------------
|
||||
@@ -623,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}")))
|
||||
|
||||
+128
-10
@@ -1,5 +1,7 @@
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing, the
|
||||
//! Block Editor.
|
||||
|
||||
pub(crate) mod bedit;
|
||||
mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -14,7 +16,10 @@ use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
mod burst;
|
||||
mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -42,6 +47,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
attedit::dash_attedit_spec(),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
|
||||
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
|
||||
.enabled(has_doc)
|
||||
.noundo(),
|
||||
CommandSpec::new("blocks.reference", "Dynamic Block Reference", run_reference)
|
||||
.params("{handle (a block reference)} → {definition, values (by parameter graph id), properties: [{name, value, choices}]} | null (not a dynamic block reference)")
|
||||
.enabled(has_doc)
|
||||
.noundo(),
|
||||
CommandSpec::new("blocks.reference.set", "Set Dynamic Property", run_reference_set)
|
||||
.params("{handle (a dynamic block reference), name (a custom property as Properties names it: a visibility state, a lookup, a distance or \"Position1 X\"), value (lengths in drawing units)} → {properties}")
|
||||
.enabled(has_doc),
|
||||
CommandSpec::new("blocks.reference.grips", "Dynamic Block Grips", dyngrip::run_grips)
|
||||
.params("{handle} → [{node, part: base|end|at, shape: linear|point|list, at: [x,y], dir: [x,y], property, choices?}] (empty: not a dynamic block reference)")
|
||||
.enabled(has_doc)
|
||||
.noundo(),
|
||||
CommandSpec::new("blocks.reference.grip", "Drag Dynamic Grip", dyngrip::run_grip)
|
||||
.params("{handle, node (the parameter's graph id), part: base|end|at, to: [x,y] (drawing coordinates)} → {properties}: a distance's arrow changes the distance (snapped to its values), a position's square moves it")
|
||||
.enabled(has_doc),
|
||||
CommandSpec::new("purge", "Purge", purge::run)
|
||||
.alias(&["pu", "-purge"])
|
||||
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
|
||||
@@ -50,11 +73,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
battman::battman_spec(),
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -84,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"));
|
||||
}
|
||||
@@ -97,6 +156,9 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
if references_block(d, &ents, name) {
|
||||
return Err(bad("block", format!("Block \"{name}\" references itself.")));
|
||||
}
|
||||
if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(name)) {
|
||||
return Err(bad("block", format!("Block \"{name}\" is being edited in place; finish with REFCLOSE first.")));
|
||||
}
|
||||
// Block names are case-insensitive: redefining keeps the existing definition's name.
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
let name = name.as_str();
|
||||
@@ -109,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));
|
||||
}
|
||||
@@ -180,6 +242,8 @@ pub(crate) fn insert_with(
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
|
||||
modes: ad.modes.for_reference(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,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()?;
|
||||
@@ -252,6 +316,8 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
|
||||
constant: false,
|
||||
prompt: prompt.into(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,9 +387,61 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::<Vec<_>>() }),
|
||||
)
|
||||
let block = |b: &Block| {
|
||||
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
|
||||
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
|
||||
};
|
||||
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// A dynamic block's parameters and actions as read from the file (null for a block that has
|
||||
/// none).
|
||||
fn run_dynamic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("blocks.dynamic", "`name` is required"))?;
|
||||
let d = s.doc()?;
|
||||
if d.block(name).is_none() && !name.trim().eq_ignore_ascii_case("*Model_Space") {
|
||||
return Err(bad("blocks.dynamic", format!("no block `{name}`")));
|
||||
}
|
||||
serde_json::to_value(cadcraft_doc::dynblock::dynamic_block(d, name)).map_err(|e| bad("blocks.dynamic", e.to_string()))
|
||||
}
|
||||
|
||||
/// The dynamic block a block reference shows, the values it was given and its custom properties
|
||||
/// (null for any other reference).
|
||||
fn run_reference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference", "`handle` is required"))?;
|
||||
let d = s.doc()?;
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(_)) => {}
|
||||
Some(_) => return Err(bad("blocks.reference", "`handle` is not a block reference")),
|
||||
None => return Err(bad("blocks.reference", format!("no object {}", h.hex()))),
|
||||
}
|
||||
let (Some(r), Some((_, properties))) = (cadcraft_doc::dynblock::reference(d, h), cadcraft_doc::dynblock::custom_properties(d, h)) else {
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
Ok(json!({ "definition": r.definition, "values": r.values, "properties": properties }))
|
||||
}
|
||||
|
||||
/// Change a custom property of a dynamic block reference (one undo step).
|
||||
fn run_reference_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blocks.reference.set";
|
||||
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?;
|
||||
let value = match p.get("value") {
|
||||
Some(Value::String(v)) => v.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "`value` is required")),
|
||||
};
|
||||
if !matches!(s.doc()?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
|
||||
return Err(bad(cmd, "`handle` is not a block reference"));
|
||||
}
|
||||
if super::curves::is_locked(s, h) {
|
||||
return Err(bad(cmd, "the reference is on a locked layer"));
|
||||
}
|
||||
let mut d = s.doc()?.clone();
|
||||
cadcraft_doc::dynblock::set_property(&mut d, h, name, &value).map_err(|e| bad(cmd, e))?;
|
||||
let properties = cadcraft_doc::dynblock::custom_properties(&d, h).map(|(_, p)| p).unwrap_or_default();
|
||||
*s.doc_mut()? = d;
|
||||
Ok(json!({ "properties": properties }))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
@@ -412,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);
|
||||
|
||||
@@ -97,7 +97,16 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
|
||||
.map(|a| a.text.value.clone())
|
||||
.unwrap_or_else(|| ad.text.value.clone());
|
||||
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
|
||||
Attrib {
|
||||
tag: ad.tag.clone(),
|
||||
text: t,
|
||||
invisible: ad.invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
|
||||
modes: ad.modes.for_reference(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! BEDIT, BSAVE, BSAVEAS and BCLOSE: the Block Editor. BEDIT opens a block definition in the
|
||||
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
|
||||
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
|
||||
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
|
||||
//! The state is [`crate::blockedit`].
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::curves::{KW, handle_value};
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("bedit", "Block Editor", run_bedit)
|
||||
.menu(&["Tools", "Block Editor"])
|
||||
.alias(&["be", "-bedit"])
|
||||
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}")
|
||||
.enabled(not_editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BeditM))),
|
||||
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
|
||||
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
|
||||
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
|
||||
.enabled(editing_a_block)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BsaveasM::default()))),
|
||||
CommandSpec::new("bclose", "Close Block Editor", run_bclose)
|
||||
.params("{save?: bool (default true; false discards the changes not saved into the block)} → {block, saved}")
|
||||
.enabled(editing)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BcloseM))),
|
||||
]
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) if st.block_edit.is_some() => Ok(()),
|
||||
Ok(_) => Err("The Block Editor is not open.".into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Block Editor is open on a block (not on the drawing itself, which BSAVEAS can't save as one).
|
||||
fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
|
||||
editing(s)?;
|
||||
match s.state() {
|
||||
Ok(st) if st.block_edit.as_ref().is_some_and(|b| b.current) => {
|
||||
Err("The Block Editor is open on the current drawing; there is no block to save under another name.".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) => match st.block_editor() {
|
||||
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
|
||||
None => Ok(()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that leave model space, which the Block Editor is: not available while it is open.
|
||||
const NOT_IN_EDITOR: &[&str] = &[
|
||||
"layout",
|
||||
"layout.new",
|
||||
"layout.delete",
|
||||
"layout.rename",
|
||||
"layout.copy",
|
||||
"layout.set",
|
||||
"mview",
|
||||
"mview.polygonal",
|
||||
"mspace",
|
||||
"pspace",
|
||||
"chspace",
|
||||
];
|
||||
|
||||
/// Why command `id` can't run now, when the Block Editor is open and it is one of [`NOT_IN_EDITOR`].
|
||||
pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
|
||||
let name = s.state().ok()?.block_editor()?;
|
||||
NOT_IN_EDITOR.contains(&id).then(|| format!("Not available in the Block Editor (editing \"{name}\"); close it first (BCLOSE)."))
|
||||
}
|
||||
|
||||
/// The block a block reference refers to.
|
||||
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dynamic block whose anonymous block `name` is (a dynamic block reference with values of
|
||||
/// its own shows one, `*U12`): its BLOCK_RECORD's `AcDbBlockRepBTag` xdata names the dynamic
|
||||
/// block's record. `None` for other blocks.
|
||||
fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
|
||||
if !name.starts_with('*') {
|
||||
return None;
|
||||
}
|
||||
let x = &d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()))?.xdata;
|
||||
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case("AcDbBlockRepBTag"))?;
|
||||
let record = x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1005).and_then(|t| Handle::parse_hex(&t.value))?;
|
||||
let (kind, block) = d.foreign.targets.get(&record)?;
|
||||
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` and frame its objects.
|
||||
fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
|
||||
let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none();
|
||||
s.state_mut()?.open_block_editor(name, create)?;
|
||||
let name = s.state()?.block_editor().unwrap_or(name).to_string();
|
||||
// The block's objects with room around them (and the editor's bar above them).
|
||||
s.zoom_extents()?;
|
||||
let c = s.state()?.view().center;
|
||||
s.zoom_about(0.75, c)?;
|
||||
s.echo(if new {
|
||||
format!("Block Editor: new block \"{name}\". BSAVE creates it in the drawing; BCLOSE closes the editor.")
|
||||
} else {
|
||||
format!("Block Editor: editing \"{name}\". BSAVE saves it into the drawing; BCLOSE closes the editor.")
|
||||
});
|
||||
let dynamic =
|
||||
|be: &crate::blockedit::BlockEdit| crate::blockedit::has_dynamic_data(&be.host.doc, if be.current { "*Model_Space" } else { &be.name });
|
||||
if s.state().is_ok_and(|st| st.block_edit.as_ref().is_some_and(dynamic)) {
|
||||
s.echo("This is a dynamic block: its parameters and actions are kept but can't be edited in CADCraft yet.");
|
||||
}
|
||||
Ok(json!({ "block": name, "new": new }))
|
||||
}
|
||||
|
||||
/// The one block reference selected, if exactly one is.
|
||||
fn selected_reference(s: &Session) -> Option<String> {
|
||||
let d = s.doc().ok()?;
|
||||
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
|
||||
match refs.as_slice() {
|
||||
[one] => Some(one.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = match (str_param(p, "name"), p.get("handle")) {
|
||||
(Some(n), _) => n.to_string(),
|
||||
(None, Some(h)) => {
|
||||
let h = handle_value(h).ok_or_else(|| bad("bedit", "`handle` must be a handle"))?;
|
||||
block_of(s.doc()?, h).ok_or_else(|| bad("bedit", "`handle` is not a block reference"))?
|
||||
}
|
||||
(None, None) => selected_reference(s).ok_or_else(|| bad("bedit", "`name` or `handle` is required (or select one block reference)"))?,
|
||||
};
|
||||
let create = bool_or(p, "create", false);
|
||||
let name = name.trim();
|
||||
let current = name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING);
|
||||
if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) {
|
||||
return Err(bad("bedit", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
open(s, name, create)
|
||||
}
|
||||
|
||||
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.state_mut()?.save_block_editor(None)?;
|
||||
let name = s.state()?.block_editor().unwrap_or_default().to_string();
|
||||
s.echo(format!("Block \"{name}\" saved."));
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
/// Check a BSAVEAS name: valid, and not another existing block unless `replace`.
|
||||
fn check_save_as(s: &Session, name: &str, replace: bool) -> Result<()> {
|
||||
if !super::valid_block_name(name) {
|
||||
return Err(bad("bsaveas", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(name));
|
||||
if !current && !replace && d.block(name).is_some() {
|
||||
return Err(bad("bsaveas", format!("Block \"{name}\" already exists; pass `replace: true` to redefine it")));
|
||||
}
|
||||
if let Some(why) = d.block(name).filter(|_| !current).and_then(|_| crate::blockedit::refusal(d, name)) {
|
||||
return Err(bad("bsaveas", why));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_bsaveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("bsaveas", "`name` is required"))?.trim().to_string();
|
||||
check_save_as(s, &name, bool_or(p, "replace", false))?;
|
||||
s.state_mut()?.save_block_editor(Some(&name))?;
|
||||
let name = s.state()?.block_editor().unwrap_or_default().to_string();
|
||||
s.echo(format!("Block \"{name}\" saved; the Block Editor now edits it."));
|
||||
Ok(json!({ "block": name }))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let name = st.block_editor().unwrap_or_default().to_string();
|
||||
let saved = save && st.block_unsaved();
|
||||
s.state_mut()?.close_block_editor(save)?;
|
||||
if saved {
|
||||
s.echo(format!("Block \"{name}\" saved."));
|
||||
}
|
||||
Ok(json!({ "block": name, "saved": saved }))
|
||||
}
|
||||
|
||||
fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
close(s, bool_or(p, "save", true))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
|
||||
struct BeditM;
|
||||
|
||||
impl Interactive for BeditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BEDIT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match selected_reference(s) {
|
||||
Some(name) => open(s, &name, false).map(|_| Step::Done),
|
||||
None => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true })
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Text(t) if t.trim() == "?" => {
|
||||
let names: Vec<String> = {
|
||||
let d = s.doc()?;
|
||||
d.blocks.values().filter(|b| crate::blockedit::refusal(d, &b.name).is_none()).map(|b| b.name.clone()).collect()
|
||||
};
|
||||
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done),
|
||||
Input::Pick(hs) => {
|
||||
let name = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().find_map(|h| block_of(d, *h))
|
||||
};
|
||||
match name {
|
||||
Some(n) => open(s, &n, false).map(|_| Step::Done),
|
||||
None => Err(EngineError::Other("Select a block reference.".into())),
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BCLOSE: asks whether to save changes not saved yet.
|
||||
struct BcloseM;
|
||||
|
||||
impl Interactive for BcloseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BCLOSE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if s.state()?.block_unsaved() { Ok(Step::Continue) } else { close(s, false).map(|_| Step::Done) }
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
|
||||
Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y")
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let answer = match i {
|
||||
Input::Enter => "Y".to_string(),
|
||||
Input::Keyword(k) | Input::Text(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
|
||||
Some('Y') => close(s, true).map(|_| Step::Done),
|
||||
Some('N') => close(s, false).map(|_| Step::Done),
|
||||
_ => Err(EngineError::Other("Enter Yes or No.".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BSAVEAS: a new block name; an existing block is redefined only when confirmed.
|
||||
#[derive(Default)]
|
||||
struct BsaveasM {
|
||||
/// The name given, waiting for "redefine?" because a block of that name exists.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl BsaveasM {
|
||||
fn save(s: &mut Session, name: &str, replace: bool) -> Result<Step> {
|
||||
run_bsaveas(s, &json!({ "name": name, "replace": replace })).map(|_| Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BsaveasM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BSAVEAS"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match &self.name {
|
||||
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), KW).kw(&["Yes", "No"]).default("N"),
|
||||
None => Prompt::new("Enter block name", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(n) = self.name.clone() {
|
||||
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_uppercase().starts_with('Y'));
|
||||
return if yes { Self::save(s, &n, true) } else { Ok(Step::Done) };
|
||||
}
|
||||
let name = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Enter => return Ok(Step::Done),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(&name));
|
||||
if !current && s.doc()?.block(&name).is_some() {
|
||||
check_save_as(s, &name, true)?;
|
||||
self.name = Some(name);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Self::save(s, &name, false)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Grips of dynamic block references: `blocks.reference.grips` lists them, `blocks.reference.grip`
|
||||
//! drags one (one undo step), and [`Session::start_dyn_grip`] starts the drag the UI's click on a
|
||||
//! 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::{Common, Drawing, Entity, EntityKind, Handle, Lineweight, Transparency};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::{bad, point_param, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Running, Session, Step};
|
||||
|
||||
/// Most objects one preview draws.
|
||||
const MAX_PREVIEW: usize = 2000;
|
||||
|
||||
/// The grips of a dynamic block reference (an empty list for any other object).
|
||||
pub(super) fn run_grips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference.grips", "`handle` is required"))?;
|
||||
serde_json::to_value(dynblock::grips(s.doc()?, h)).map_err(|e| bad("blocks.reference.grips", e.to_string()))
|
||||
}
|
||||
|
||||
/// Drag a grip of a dynamic block reference to a point (one undo step).
|
||||
pub(super) fn run_grip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blocks.reference.grip";
|
||||
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
|
||||
let node = p
|
||||
.get("node")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|n| i32::try_from(n).ok())
|
||||
.ok_or_else(|| bad(cmd, "`node` (a parameter's graph id) is required"))?;
|
||||
let part = str_param(p, "part").and_then(GripPart::from_name).ok_or_else(|| bad(cmd, "`part` must be base, end or at"))?;
|
||||
let to = point_param(p, "to").ok_or_else(|| bad(cmd, "`to` [x, y] is required"))?;
|
||||
drag(s, h, node, part, to).map_err(|e| bad(cmd, e))?;
|
||||
let properties = dynblock::custom_properties(s.doc()?, h).map(|(_, p)| p).unwrap_or_default();
|
||||
Ok(json!({ "properties": properties }))
|
||||
}
|
||||
|
||||
/// Drag grip (`node`, `part`) of reference `h` to `to` in the session's drawing.
|
||||
fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std::result::Result<(), String> {
|
||||
if !matches!(s.doc().map_err(|e| e.to_string())?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
|
||||
return Err("`handle` is not a block reference".into());
|
||||
}
|
||||
if super::super::curves::is_locked(s, h) {
|
||||
return Err("the reference is on a locked layer".into());
|
||||
}
|
||||
let mut d = s.doc().map_err(|e| e.to_string())?.clone();
|
||||
dynblock::drag_grip(&mut d, h, node, part, to)?;
|
||||
*s.doc_mut().map_err(|e| e.to_string())? = d;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<Entity> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
|
||||
.take(MAX_PREVIEW)
|
||||
.map(|e| {
|
||||
let mut kind = e.kind.clone();
|
||||
kind.transform(&m);
|
||||
Entity { handle: Handle(0), common: under(&e.common, r), kind }
|
||||
})
|
||||
.collect();
|
||||
// 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
|
||||
}
|
||||
|
||||
/// A dynamic grip being dragged.
|
||||
struct DynGripEdit {
|
||||
handle: Handle,
|
||||
node: i32,
|
||||
part: GripPart,
|
||||
/// Where the grip is (the rubber band's start).
|
||||
from: Vec2,
|
||||
/// The property it changes, and whether it is a distance (a typed number sets it).
|
||||
property: String,
|
||||
distance: bool,
|
||||
}
|
||||
|
||||
impl Interactive for DynGripEdit {
|
||||
fn name(&self) -> &'static str {
|
||||
"GRIP"
|
||||
}
|
||||
|
||||
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let (msg, accept) = if self.distance {
|
||||
("** STRETCH ** Specify stretch point or distance", Accept::POINT_OR_NUMBER)
|
||||
} else {
|
||||
("** STRETCH ** Specify stretch point", Accept::POINT)
|
||||
};
|
||||
Prompt::new(msg, accept).kw(&["eXit"]).base(self.from)
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let to = match i {
|
||||
Input::Point(p) => p,
|
||||
Input::Deferred(d) => d.at,
|
||||
Input::Text(t) if self.distance => {
|
||||
let mut d = s.doc()?.clone();
|
||||
dynblock::set_property(&mut d, self.handle, &self.property, t.trim()).map_err(EngineError::Other)?;
|
||||
*s.doc_mut()? = d;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Input::Keyword(k) if k == "eXit" => return Ok(Step::Done),
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
_ => return Err(EngineError::Other("Requires a point.".into())),
|
||||
};
|
||||
drag(s, self.handle, self.node, self.part, to).map_err(EngineError::Other)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
|
||||
Ok(()) => drawn(&d, self.handle),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start dragging grip `part` of parameter `node` of dynamic block reference `handle` (the
|
||||
/// UI's click on a dynamic grip). A list grip (visibility, lookup) has no drag: the UI offers
|
||||
/// its choices and sets them with `blocks.reference.set`.
|
||||
pub fn start_dyn_grip(&mut self, handle: Handle, node: i32, part: GripPart) -> Result<()> {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
let g = dynblock::grips(self.doc()?, handle)
|
||||
.into_iter()
|
||||
.find(|g| g.node == node && g.part == part)
|
||||
.ok_or_else(|| EngineError::Other("no such grip".into()))?;
|
||||
if g.shape == GripShape::List {
|
||||
return Err(EngineError::Other("a list grip is chosen from, not dragged".into()));
|
||||
}
|
||||
let edit = DynGripEdit { handle, node, part, from: g.at, property: g.property, distance: g.shape == GripShape::Linear };
|
||||
let machine = crate::guard::Guarded::wrap(Box::new(edit));
|
||||
let st = self.state()?;
|
||||
let (before, selection_before) = (st.doc.clone(), st.selection.clone());
|
||||
self.running = Some(Running { id: "grip".into(), machine, before, selection_before });
|
||||
self.pending_window = None;
|
||||
self.point_mods.clear();
|
||||
self.echo("** STRETCH **");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, groups) and
|
||||
//! zero-length geometry or empty text.
|
||||
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, multiline
|
||||
//! styles, groups) and zero-length geometry or empty text.
|
||||
//!
|
||||
//! The JSON form takes `{type?, names?}` and purges without asking. Typed, it asks for the type,
|
||||
//! the names and whether to verify each one, as AutoCAD's -PURGE does.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, STANDARD_MLINE_STYLE};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::super::qselect::wildcard;
|
||||
@@ -21,6 +21,7 @@ enum Kind {
|
||||
Layers,
|
||||
Linetypes,
|
||||
MleaderStyles,
|
||||
MlineStyles,
|
||||
TableStyles,
|
||||
TextStyles,
|
||||
ZeroLength,
|
||||
@@ -43,7 +44,7 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
|
||||
("Plotstyles", &["plotstyles"], Kind::Absent),
|
||||
("SHapes", &["shapes"], Kind::Absent),
|
||||
("textSTyles", &["textstyles", "textstyle", "styles", "style"], Kind::TextStyles),
|
||||
("Mlinestyles", &["mlinestyles"], Kind::Absent),
|
||||
("Mlinestyles", &["mlinestyles", "mlinestyle"], Kind::MlineStyles),
|
||||
("SEctionviewstyles", &["sectionviewstyles"], Kind::Absent),
|
||||
("Tablestyles", &["tablestyles", "tablestyle"], Kind::TableStyles),
|
||||
("Visualstyles", &["visualstyles"], Kind::Absent),
|
||||
@@ -56,8 +57,17 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
|
||||
|
||||
/// The named kinds `All` purges, in an order where purging one frees the next (blocks free
|
||||
/// layers, layers free linetypes, dimension styles free text styles).
|
||||
const ALL: &[Kind] =
|
||||
&[Kind::Blocks, Kind::Groups, Kind::Dimstyles, Kind::MleaderStyles, Kind::TableStyles, Kind::Layers, Kind::Linetypes, Kind::TextStyles];
|
||||
const ALL: &[Kind] = &[
|
||||
Kind::Blocks,
|
||||
Kind::Groups,
|
||||
Kind::Dimstyles,
|
||||
Kind::MleaderStyles,
|
||||
Kind::MlineStyles,
|
||||
Kind::TableStyles,
|
||||
Kind::Layers,
|
||||
Kind::Linetypes,
|
||||
Kind::TextStyles,
|
||||
];
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
@@ -77,6 +87,7 @@ fn noun(kind: Kind) -> &'static str {
|
||||
Kind::Layers => "layer",
|
||||
Kind::Linetypes => "linetype",
|
||||
Kind::MleaderStyles => "multileader style",
|
||||
Kind::MlineStyles => "multiline style",
|
||||
Kind::TableStyles => "table style",
|
||||
Kind::TextStyles => "text style",
|
||||
Kind::ZeroLength => "zero-length object",
|
||||
@@ -93,6 +104,7 @@ fn json_key(kind: Kind) -> &'static str {
|
||||
Kind::Layers => "layers",
|
||||
Kind::Linetypes => "linetypes",
|
||||
Kind::MleaderStyles => "mleaderstyles",
|
||||
Kind::MlineStyles => "mlinestyles",
|
||||
Kind::TableStyles => "tablestyles",
|
||||
Kind::TextStyles => "textstyles",
|
||||
Kind::ZeroLength => "zeroLength",
|
||||
@@ -198,6 +210,15 @@ fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::MlineStyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::MLine(m) = &e.kind { Some(m.style.clone()) } else { None }));
|
||||
let cur = d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE);
|
||||
d.mline_styles
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &[STANDARD_MLINE_STYLE, &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::TableStyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.style.clone()) } else { None }));
|
||||
let cur = d.header.str("CTABLESTYLE", "Standard");
|
||||
@@ -238,6 +259,7 @@ fn purge_one(s: &mut Session, kind: Kind, name: &str) -> Result<bool> {
|
||||
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, name),
|
||||
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, name),
|
||||
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, name),
|
||||
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, name),
|
||||
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, name),
|
||||
Kind::Groups => retain(&mut d.groups, |g| &g.name, name),
|
||||
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! REFEDIT, REFSET and REFCLOSE: edit a block reference in place.
|
||||
//!
|
||||
//! REFEDIT hides the picked reference and puts the block's objects (all, or the ones named) into
|
||||
//! the drawing where the reference shows them: the *working set*, ordinary objects that every
|
||||
//! command edits. The rest of the block stands there as faded copies, and everything else in the
|
||||
//! drawing is locked ([`crate::cmd::curves::is_locked`], enforced by [`after_command`]). Objects
|
||||
//! drawn while editing join the working set. REFSET adds objects to or removes them from it.
|
||||
//! REFCLOSE writes the working set back into the block definition, so every reference to the
|
||||
//! block shows the change, or discards the edit.
|
||||
//!
|
||||
//! The state is [`Drawing::refedit`]: it travels with undo, is never written to a file (files
|
||||
//! are written from [`Drawing::committed`]) and ends with the drawing it belongs to.
|
||||
//!
|
||||
//! Not yet: external references (their source drawing would have to be written back), nested
|
||||
//! references picked at a depth, annotative scale contexts and attribute synchronisation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, RefEdit, Transparency, WorkItem};
|
||||
use cadcraft_geom::Mat3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::handle_value;
|
||||
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::references_block;
|
||||
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Transparency of the faded copies of the block's other objects.
|
||||
const GHOST_FADE: u8 = 70;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("refedit", "Edit Reference In-Place", run_refedit)
|
||||
.menu(&["Tools", "Xref and Block In-place Editing", "Edit Reference In-Place"])
|
||||
.params("{handle? | handles? | name? (a block reference, default the selection), objects?: [block object handles] (default all)} → {block, insert, working: [handles], faded}")
|
||||
.enabled(has_doc)
|
||||
.interactive(|_| Ok(Box::new(SelectRun::single("refedit", "REFEDIT", "Select reference")))),
|
||||
CommandSpec::new("refset", "Working Set", run_refset)
|
||||
.menu(&["Tools", "Xref and Block In-place Editing", "Add to Working Set"])
|
||||
.params("{add?: [handles], remove?: [handles]} (or {handles, mode?: \"add\"|\"remove\"}); with neither: lists the working set → {working: [handles], added, removed}")
|
||||
.enabled(editing)
|
||||
.interactive(|_| Ok(Box::new(RefsetM::default()))),
|
||||
CommandSpec::new("refclose", "Save Reference Edits", run_refclose)
|
||||
.menu(&["Tools", "Xref and Block In-place Editing", "Save Reference Edits"])
|
||||
.params("{option?: \"save\"|\"discard\" | save?: bool (default true)} → {block, saved, objects}")
|
||||
.enabled(editing)
|
||||
.interactive(|_| Ok(Box::new(RefcloseM))),
|
||||
]
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.doc().is_ok_and(|d| d.refedit.is_some()) { Ok(()) } else { Err("no block reference is being edited (REFEDIT)".into()) }
|
||||
}
|
||||
|
||||
fn active(s: &Session, cmd: &str) -> Result<RefEdit> {
|
||||
s.doc()?.refedit.clone().ok_or_else(|| bad(cmd, "no block reference is being edited (REFEDIT)"))
|
||||
}
|
||||
|
||||
/// A block object as the drawing shows it through the edited reference.
|
||||
fn to_host(to_world: &Mat3, e: &Entity, h: Handle, ghost: bool) -> Entity {
|
||||
let mut c = e.clone();
|
||||
c.handle = h;
|
||||
c.kind.transform(to_world);
|
||||
if ghost {
|
||||
c.common.transparency = Transparency::Percent(GHOST_FADE);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A drawing object as it belongs in the block, with the block object's handle `h`.
|
||||
fn to_block(inv: &Mat3, e: &Entity, h: Handle) -> Entity {
|
||||
let mut c = e.clone();
|
||||
c.handle = h;
|
||||
c.kind.transform(inv);
|
||||
c
|
||||
}
|
||||
|
||||
fn put(d: &mut Drawing, r: &RefEdit, e: Entity) -> Result<()> {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
d.space_mut(&r.space).ok_or_else(|| bad("refedit", "the space of the reference is gone"))?.push(e);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hexes(hs: impl IntoIterator<Item = Handle>) -> Vec<String> {
|
||||
hs.into_iter().map(|h| h.hex()).collect()
|
||||
}
|
||||
|
||||
// ---------------- REFEDIT ----------------
|
||||
|
||||
fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.refedit.is_some() {
|
||||
return Err(bad("refedit", "a reference is already being edited; finish with REFCLOSE"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let picked = match str_param(p, "name") {
|
||||
Some(n) => d
|
||||
.model
|
||||
.iter()
|
||||
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
|
||||
.find(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(n)))
|
||||
.map(|e| e.handle),
|
||||
None => targets(s, p)?.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, cadcraft_doc::EntityKind::Insert(_)))),
|
||||
};
|
||||
let ih = picked.ok_or_else(|| bad("refedit", "select a block reference"))?;
|
||||
let (ins, space) = match d.entity(ih).map(|e| &e.kind) {
|
||||
Some(cadcraft_doc::EntityKind::Insert(i)) => (i.clone(), d.space_of(ih).ok_or_else(|| bad("refedit", "the reference is in no space"))?),
|
||||
_ => return Err(bad("refedit", "select a block reference")),
|
||||
};
|
||||
let blk = d.block(&ins.block).cloned().ok_or_else(|| bad("refedit", format!("block \"{}\" not found", ins.block)))?;
|
||||
if blk.xref_path.is_some() {
|
||||
return Err(bad(
|
||||
"refedit",
|
||||
format!(
|
||||
"\"{}\" is an external reference: editing it in place would write its source drawing, which is not available yet. Open it with XOPEN, edit and save it, then reload.",
|
||||
blk.name
|
||||
),
|
||||
));
|
||||
}
|
||||
if blk.name.starts_with('*') || blk.array.is_some() {
|
||||
return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name)));
|
||||
}
|
||||
let to_world = ins.transform(blk.base.xy());
|
||||
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
|
||||
let finite = [to_world.a, to_world.b, to_world.c, to_world.d, to_world.e, to_world.f].iter().all(|v| v.is_finite());
|
||||
if !finite || to_world.det().abs() < 1e-12 || (sx - sy).abs() > 1e-9 * sx.max(sy) {
|
||||
return Err(bad("refedit", "the reference must be scaled equally in X and Y (and not flat) to be edited in place"));
|
||||
}
|
||||
let chosen: Option<HashSet<Handle>> = match p.get("objects") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => None,
|
||||
Some(Value::Array(a)) => {
|
||||
let mut set = HashSet::new();
|
||||
for v in a {
|
||||
let h = handle_value(v).ok_or_else(|| bad("refedit", format!("`objects`: {v} is not an object handle")))?;
|
||||
if blk.entities.get(h).is_none() {
|
||||
return Err(bad("refedit", format!("object {} is not in block \"{}\"", h.hex(), blk.name)));
|
||||
}
|
||||
set.insert(h);
|
||||
}
|
||||
if set.is_empty() {
|
||||
return Err(bad("refedit", "`objects` is empty"));
|
||||
}
|
||||
Some(set)
|
||||
}
|
||||
Some(v) => return Err(bad("refedit", format!("`objects` must be \"all\" or an array of handles, got {v}"))),
|
||||
};
|
||||
let was_visible = d.entity(ih).is_some_and(|e| e.common.visible);
|
||||
let mut r = RefEdit {
|
||||
block: blk.name.clone(),
|
||||
insert: ih,
|
||||
insert_was_visible: was_visible,
|
||||
space,
|
||||
to_world,
|
||||
items: Vec::new(),
|
||||
ghosts: Vec::new(),
|
||||
released: Vec::new(),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
for e in blk.entities.iter() {
|
||||
let working = chosen.as_ref().is_none_or(|c| c.contains(&e.handle));
|
||||
let h = doc.new_handle();
|
||||
put(doc, &r, to_host(&to_world, e, h, !working))?;
|
||||
if working {
|
||||
r.items.push(WorkItem { copy: h, source: Some(e.handle) });
|
||||
} else {
|
||||
r.ghosts.push((h, e.handle));
|
||||
}
|
||||
}
|
||||
doc.modify_entity(ih, |e| e.common.visible = false).map_err(|e| bad("refedit", e.to_string()))?;
|
||||
let out = json!({ "block": r.block, "insert": ih.hex(), "working": hexes(r.items.iter().map(|i| i.copy)), "faded": r.ghosts.len() });
|
||||
let sel: Vec<Handle> = r.items.iter().map(|i| i.copy).collect();
|
||||
doc.refedit = Some(r);
|
||||
s.set_selection(sel);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------- REFSET ----------------
|
||||
|
||||
fn handle_list(p: &Value, key: &str) -> Result<Vec<Handle>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(Vec::new()),
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().map(|v| handle_value(v).ok_or_else(|| bad("refset", format!("`{key}`: {v} is not an object handle")))).collect()
|
||||
}
|
||||
Some(v) => Err(bad("refset", format!("`{key}` must be an array of object handles, got {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_refset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut add, mut remove) = (handle_list(p, "add")?, handle_list(p, "remove")?);
|
||||
let hs = handle_list(p, "handles")?;
|
||||
if str_param(p, "mode").is_some_and(|m| m.to_ascii_lowercase().starts_with('r')) {
|
||||
remove.extend(hs)
|
||||
} else {
|
||||
add.extend(hs)
|
||||
}
|
||||
apply_refset(s, &add, &remove)
|
||||
}
|
||||
|
||||
fn apply_refset(s: &mut Session, add: &[Handle], remove: &[Handle]) -> Result<Value> {
|
||||
let mut r = active(s, "refset")?;
|
||||
let inv = r.to_world.inverse().ok_or_else(|| bad("refset", "the reference can't be inverted"))?;
|
||||
let d = s.doc()?;
|
||||
for h in add.iter().chain(remove) {
|
||||
if d.entity(*h).is_none() {
|
||||
return Err(bad("refset", format!("no object {}", h.hex())));
|
||||
}
|
||||
}
|
||||
if let Some(h) = remove.iter().find(|h| !r.is_working(**h)) {
|
||||
return Err(bad("refset", format!("object {} is not in the working set", h.hex())));
|
||||
}
|
||||
let blk = d.block(&r.block).cloned().ok_or_else(|| bad("refset", format!("block \"{}\" is gone; REFCLOSE Discard", r.block)))?;
|
||||
let (mut added, mut removed) = (0usize, 0usize);
|
||||
let doc = s.doc_mut()?;
|
||||
for h in add {
|
||||
if *h == r.insert || r.is_working(*h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pos) = r.ghosts.iter().position(|(g, _)| g == h) {
|
||||
// A faded block object joins the working set (with the edits it had before).
|
||||
let (g, src) = r.ghosts.remove(pos);
|
||||
doc.remove_entity(g);
|
||||
let cur = match r.released.iter().position(|e| e.handle == src) {
|
||||
Some(i) => Some(r.released.remove(i)),
|
||||
None => blk.entities.get(src).map(|e| (**e).clone()),
|
||||
};
|
||||
if let Some(cur) = cur {
|
||||
let c = doc.new_handle();
|
||||
put(doc, &r, to_host(&r.to_world, &cur, c, false))?;
|
||||
r.items.push(WorkItem { copy: c, source: Some(src) });
|
||||
added += 1;
|
||||
}
|
||||
} else {
|
||||
r.items.push(WorkItem { copy: *h, source: None });
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
for h in remove {
|
||||
let Some(pos) = r.items.iter().position(|i| i.copy == *h) else { continue };
|
||||
let item = r.items.remove(pos);
|
||||
if let Some(src) = item.source
|
||||
&& let Some(e) = doc.entity(*h).map(|e| (**e).clone())
|
||||
{
|
||||
// Back to the block, edits kept, shown faded.
|
||||
let kept = to_block(&inv, &e, src);
|
||||
doc.remove_entity(*h);
|
||||
let g = doc.new_handle();
|
||||
put(doc, &r, to_host(&r.to_world, &kept, g, true))?;
|
||||
r.ghosts.push((g, src));
|
||||
r.released.push(kept);
|
||||
}
|
||||
removed += 1;
|
||||
}
|
||||
let working = hexes(r.items.iter().map(|i| i.copy));
|
||||
doc.refedit = Some(r);
|
||||
Ok(json!({ "working": working, "added": added, "removed": removed }))
|
||||
}
|
||||
|
||||
// ---------------- REFCLOSE ----------------
|
||||
|
||||
fn run_refclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let save = match str_param(p, "option") {
|
||||
Some(o) if o.to_ascii_lowercase().starts_with('d') => false,
|
||||
Some(o) if o.to_ascii_lowercase().starts_with('s') => true,
|
||||
Some(o) => return Err(bad("refclose", format!("unknown option \"{o}\" (save or discard)"))),
|
||||
None => bool_or(p, "save", true),
|
||||
};
|
||||
close(s, save)
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, save: bool) -> Result<Value> {
|
||||
let r = active(s, "refclose")?;
|
||||
// Work on a copy: the drawing changes only when everything went through.
|
||||
let mut nd: Drawing = s.doc()?.clone();
|
||||
let mut objects = 0;
|
||||
if save {
|
||||
objects = commit_block(&mut nd, &r)?;
|
||||
}
|
||||
nd.end_refedit_discard(&r);
|
||||
*s.doc_mut()? = nd;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
|
||||
}
|
||||
|
||||
/// Replace the block's objects by the working set (and move the drawing objects added to it
|
||||
/// into the block). Returns the number of objects the block has now.
|
||||
fn commit_block(nd: &mut Drawing, r: &RefEdit) -> Result<usize> {
|
||||
let blk = nd.block(&r.block).cloned().ok_or_else(|| bad("refclose", format!("block \"{}\" is gone; use REFCLOSE Discard", r.block)))?;
|
||||
let inv = r.to_world.inverse().ok_or_else(|| bad("refclose", "the reference can't be inverted; use REFCLOSE Discard"))?;
|
||||
let mut ents: Vec<Entity> = Vec::new();
|
||||
for e in blk.entities.iter() {
|
||||
match r.items.iter().find(|i| i.source == Some(e.handle)) {
|
||||
// A working object the user erased leaves the block.
|
||||
Some(i) => {
|
||||
if let Some(c) = nd.entity(i.copy) {
|
||||
ents.push(to_block(&inv, c, e.handle));
|
||||
}
|
||||
}
|
||||
None => match r.released.iter().find(|x| x.handle == e.handle) {
|
||||
Some(x) => ents.push(x.clone()),
|
||||
None => ents.push((**e).clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
let mut moved = Vec::new();
|
||||
for i in r.items.iter().filter(|i| i.source.is_none()) {
|
||||
if let Some(c) = nd.entity(i.copy).map(|c| (**c).clone()) {
|
||||
let h = nd.new_handle();
|
||||
ents.push(to_block(&inv, &c, h));
|
||||
moved.push(i.copy);
|
||||
}
|
||||
}
|
||||
if references_block(nd, &ents, &r.block) {
|
||||
return Err(bad("refclose", format!("Block \"{}\" would reference itself; remove that reference or use REFCLOSE Discard.", r.block)));
|
||||
}
|
||||
let n = ents.len();
|
||||
let mut store = EntityStore::new();
|
||||
for e in ents {
|
||||
store.push(e);
|
||||
}
|
||||
let key = nd.blocks.keys().find(|k| k.eq_ignore_ascii_case(&r.block)).cloned().unwrap_or_else(|| r.block.clone());
|
||||
let mut nb = (*blk).clone();
|
||||
nb.entities = store;
|
||||
nd.blocks.insert(key, Arc::new(nb));
|
||||
for h in moved {
|
||||
nd.remove_entity(h);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ---------------- while editing ----------------
|
||||
|
||||
/// After a command ran: objects it created join the working set, and a command that changed
|
||||
/// anything outside the working set is undone (returns the reason).
|
||||
pub(crate) fn after_command(s: &mut Session, before: &Arc<Drawing>, id: &str) -> Option<String> {
|
||||
if matches!(id, "refedit" | "refset" | "refclose") || !crate::cmd::find_command(id).is_some_and(|c| c.undoable) {
|
||||
return None;
|
||||
}
|
||||
let rb = before.refedit.as_ref()?;
|
||||
let st = s.state_mut().ok()?;
|
||||
if Arc::ptr_eq(before, &st.doc) {
|
||||
return None;
|
||||
}
|
||||
let ra = st.doc.refedit.as_ref().filter(|ra| ra.insert == rb.insert)?;
|
||||
let owned: HashSet<Handle> = rb.items.iter().map(|i| i.copy).chain(rb.ghosts.iter().map(|g| g.0)).chain([rb.insert]).collect();
|
||||
let stores = |d: &Drawing| std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).cloned().collect::<Vec<_>>();
|
||||
let touched = stores(before)
|
||||
.iter()
|
||||
.any(|store| store.iter().any(|e| !owned.contains(&e.handle) && !st.doc.entity(e.handle).is_some_and(|a| Arc::ptr_eq(a, e))));
|
||||
if touched {
|
||||
st.doc = before.clone();
|
||||
return Some("Only the working set can be edited during REFEDIT (REFSET adds objects, REFCLOSE ends the edit).".into());
|
||||
}
|
||||
let seed = before.handseed;
|
||||
let mut new: Vec<Handle> = Vec::new();
|
||||
for store in stores(&st.doc) {
|
||||
new.extend(store.iter().map(|e| e.handle).filter(|h| h.0 >= seed && !ra.owns(*h)));
|
||||
}
|
||||
if !new.is_empty()
|
||||
&& let Some(r) = Arc::make_mut(&mut st.doc).refedit.as_mut()
|
||||
{
|
||||
r.items.extend(new.into_iter().map(|copy| WorkItem { copy, source: None }));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `h` may not be edited because a reference is being edited and it is not in the working set.
|
||||
pub(crate) fn locked(d: &Drawing, h: Handle) -> bool {
|
||||
d.refedit.as_ref().is_some_and(|r| !r.is_working(h))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
#[derive(Default)]
|
||||
enum RStage {
|
||||
#[default]
|
||||
Mode,
|
||||
Pick(bool),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RefsetM {
|
||||
stage: RStage,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Interactive for RefsetM {
|
||||
fn name(&self) -> &'static str {
|
||||
"REFSET"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
active(s, "refset").map(|_| Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
RStage::Mode => {
|
||||
let mut p = Prompt::new("Enter an option [Add/Remove] <Add>", Accept::TEXT);
|
||||
p.keywords = vec!["Add".into(), "Remove".into()];
|
||||
p.default = Some("Add".into());
|
||||
p
|
||||
}
|
||||
RStage::Pick(remove) => {
|
||||
let mut p = self.sel.prompt();
|
||||
p.message = format!("Select objects to {}", if remove { "remove" } else { "add" });
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
RStage::Mode => {
|
||||
let remove = match i {
|
||||
Input::Keyword(k) | Input::Text(k) => k.to_ascii_lowercase().starts_with('r'),
|
||||
Input::Enter => false,
|
||||
_ => return Ok(Step::Cancel),
|
||||
};
|
||||
self.sel = SelectPhase::begin(s);
|
||||
self.stage = RStage::Pick(remove);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.finish(s, remove, &hs);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
RStage::Pick(remove) => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.finish(s, remove, &hs),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RefsetM {
|
||||
fn finish(&mut self, s: &mut Session, remove: bool, hs: &[Handle]) -> Result<Step> {
|
||||
let r = if remove { apply_refset(s, &[], hs) } else { apply_refset(s, hs, &[]) }?;
|
||||
s.echo(format!("{} object(s) in the working set.", r.get("working").and_then(Value::as_array).map_or(0, Vec::len)));
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
struct RefcloseM;
|
||||
|
||||
impl Interactive for RefcloseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"REFCLOSE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let mut p = Prompt::new("Enter option [Save/Discard reference changes] <Save>", Accept::TEXT);
|
||||
p.keywords = vec!["Save".into(), "Discard".into()];
|
||||
p.default = Some("Save".into());
|
||||
p
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let save = match i {
|
||||
Input::Keyword(k) | Input::Text(k) => !k.to_ascii_lowercase().starts_with('d'),
|
||||
Input::Enter => true,
|
||||
_ => return Ok(Step::Cancel),
|
||||
};
|
||||
let r = close(s, save)?;
|
||||
s.echo(if save { "Reference changes saved." } else { "Reference changes discarded." });
|
||||
let _ = r;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -36,9 +36,9 @@ pub fn handle_param(p: &serde_json::Value, key: &str) -> Option<Handle> {
|
||||
p.get(key).and_then(handle_value)
|
||||
}
|
||||
|
||||
/// Is the entity on a locked layer?
|
||||
/// Is the entity on a locked layer, or outside the working set while a block reference is edited in place (REFEDIT)?
|
||||
pub fn is_locked(s: &Session, h: Handle) -> bool {
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked) || super::blocks::refedit::locked(d, h))
|
||||
}
|
||||
|
||||
// ---------------- arc constructions ----------------
|
||||
@@ -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 => {
|
||||
|
||||
@@ -271,7 +271,7 @@ fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?)
|
||||
} else if let Some(c) = points_param(p, "control") {
|
||||
if c.len() < 2 {
|
||||
if c.len() < 2 || c.len() > super::curves::MAX_GEN {
|
||||
return Err(bad("spline", "need 2+ control points"));
|
||||
}
|
||||
// Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows
|
||||
@@ -378,7 +378,7 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
attach,
|
||||
rotation: f64_or(p, "rotation", 0.0).to_radians(),
|
||||
style,
|
||||
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"))?,
|
||||
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?,
|
||||
line_spacing: f64_or(p, "lineSpacing", 1.0),
|
||||
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
|
||||
columns: super::mtext_opts::json_columns(p, height, width)?,
|
||||
@@ -428,7 +428,21 @@ impl Interactive for LineM {
|
||||
&& let Some(first) = self.first
|
||||
{
|
||||
match i {
|
||||
Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)),
|
||||
Input::Point(p) => {
|
||||
// A deferred TAN first point with a typed `@d<a` next point: resolve
|
||||
// exactly from the direction and distance the user gave.
|
||||
if let Some((dist, ang)) = s.last_polar.take()
|
||||
&& let Some(t) = snap::tangent_point_in_direction(&first.curve, ang, first.at)
|
||||
{
|
||||
let e = t + Vec2::new(ang.cos(), ang.sin()) * dist;
|
||||
self.handles.push(s.add_entity(line(t, e))?);
|
||||
self.pts = vec![t, e];
|
||||
s.last_point = e;
|
||||
self.first = None;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return self.resolve_first(s, first, snap::LineEnd::Point(p));
|
||||
}
|
||||
Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -741,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(
|
||||
@@ -780,6 +797,9 @@ impl Interactive for CircleM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if self.mode == 0 && self.pts.first().is_some_and(|c| c.near(p, 1e-12)) {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
self.pts.push(p);
|
||||
let done = match self.mode {
|
||||
0 if self.pts.len() == 2 => {
|
||||
@@ -873,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> {
|
||||
@@ -895,6 +915,9 @@ impl Interactive for PolygonM {
|
||||
let n = self.sides.unwrap_or(4);
|
||||
if self.edge {
|
||||
if let Input::Point(p) = i {
|
||||
if self.edge_pts.first().is_some_and(|a| a.near(p, 1e-12)) {
|
||||
return Err(crate::EngineError::Other("Edge endpoints must differ.".into()));
|
||||
}
|
||||
self.edge_pts.push(p);
|
||||
if self.edge_pts.len() == 2 {
|
||||
s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?;
|
||||
@@ -917,11 +940,17 @@ impl Interactive for PolygonM {
|
||||
s.last_used.polygon_circumscribed = !ins;
|
||||
}
|
||||
(Some(c), Some(ins), Input::Point(p)) => {
|
||||
if c.near(p, 1e-12) {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(Some(c), Some(ins), Input::Text(t)) => {
|
||||
let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?;
|
||||
if !r.is_finite() || r <= 0.0 {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
let rp = polygon_typed_radius_point(c, n, r, ins);
|
||||
s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?;
|
||||
return Ok(Step::Done);
|
||||
@@ -1412,11 +1441,22 @@ struct TextM {
|
||||
justify: (HAlign, VAlign),
|
||||
asking_justify: bool,
|
||||
line: usize,
|
||||
/// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's
|
||||
/// `command` runs TEXT; typed and in scripts each line asks for the next.
|
||||
single_line: bool,
|
||||
}
|
||||
|
||||
impl TextM {
|
||||
fn new(_s: &Session) -> Self {
|
||||
TextM { at: None, height: None, rotation: None, justify: (HAlign::Left, VAlign::Baseline), asking_justify: false, line: 0 }
|
||||
fn new(s: &Session) -> Self {
|
||||
TextM {
|
||||
at: None,
|
||||
height: None,
|
||||
rotation: None,
|
||||
justify: (HAlign::Left, VAlign::Baseline),
|
||||
asking_justify: false,
|
||||
line: 0,
|
||||
single_line: s.lisp_running(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1477,6 +1517,9 @@ impl Interactive for TextM {
|
||||
let k = make_text(s, at, &t, h, r, self.justify);
|
||||
s.add_entity(k)?;
|
||||
self.line += 1;
|
||||
if self.single_line {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
@@ -1562,6 +1605,10 @@ impl Interactive for MTextM {
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn editing_mtext(&self) -> Option<MText> {
|
||||
let a = self.first?;
|
||||
(self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new()))
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.first, self.boxed(), self.ask) {
|
||||
(Some(a), false, None) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -609,6 +609,12 @@ impl Interactive for PasteM {
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.mode == PasteMode::Orig || s.clipboard.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::prompt::Drag { handles: Vec::new(), matrix: Mat3::translate(c - s.clipboard_base), extra: Vec::new(), clipboard: true })
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
|
||||
let m = Mat3::translate(c - s.clipboard_base);
|
||||
s.clipboard
|
||||
|
||||
@@ -43,12 +43,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("preview", "Plot Preview", run_preview)
|
||||
.menu(&["File", "Plot Preview"])
|
||||
.alias(&["pre"])
|
||||
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
|
||||
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, plotTransparency?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
|
||||
.noundo(),
|
||||
CommandSpec::new("publish", "Batch Publish...", run_publish)
|
||||
.menu(&["File", "Batch Publish..."])
|
||||
.params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")
|
||||
.noundo(),
|
||||
CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook)
|
||||
.menu(&["File", "Sheet Books to PDF..."])
|
||||
.alias(&["printpdf", "pdfb"])
|
||||
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
|
||||
.noundo(),
|
||||
CommandSpec::new("attach", "Attach", |_, _| {
|
||||
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
|
||||
})
|
||||
@@ -287,9 +292,9 @@ fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64
|
||||
fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let space = plot_space("preview", s, p)?;
|
||||
let d = s.doc()?;
|
||||
let mut page = match &space {
|
||||
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
|
||||
Space::Model => {
|
||||
let mut page = match space.layout() {
|
||||
Some(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
|
||||
None => {
|
||||
// Model space plots on A4 (metric) or ANSI A, like the PDF plotter.
|
||||
let mut page = PageSetup::default();
|
||||
if paper::paper_unit_mm(d) == 1.0
|
||||
@@ -311,11 +316,9 @@ 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 lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(match space {
|
||||
Space::Paper(_) => page.lineweights,
|
||||
Space::Model => true,
|
||||
});
|
||||
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));
|
||||
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
|
||||
@@ -331,6 +334,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
text: true,
|
||||
fill: true,
|
||||
lineweights,
|
||||
transparency,
|
||||
..Default::default()
|
||||
};
|
||||
let list = cadcraft_render::build_plot(d, &space, &ropts);
|
||||
@@ -355,10 +359,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
antialias: true,
|
||||
};
|
||||
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
let layout = match &space {
|
||||
Space::Model => "Model".to_string(),
|
||||
Space::Paper(n) => n.clone(),
|
||||
};
|
||||
let layout = space.tab_name();
|
||||
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
|
||||
}
|
||||
|
||||
@@ -421,6 +422,122 @@ fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "sheets": out, "message": message }))
|
||||
}
|
||||
|
||||
// ---------- PDFBOOK ----------
|
||||
|
||||
/// Frame sizes match a paper to this many millimetres.
|
||||
const FRAME_TOL_MM: f64 = 0.5;
|
||||
/// Most frames one PDFBOOK looks at.
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
|
||||
/// A sheet frame found in model space.
|
||||
struct Frame {
|
||||
window: Bounds2,
|
||||
paper: &'static str,
|
||||
landscape: bool,
|
||||
}
|
||||
|
||||
/// The ISO/ANSI paper a frame of `w` x `h` mm fits (either orientation), and whether it is landscape.
|
||||
fn paper_for(w: f64, h: f64) -> Option<(&'static str, bool)> {
|
||||
cadcraft_render::PAPER_SIZES.iter().find_map(|p| {
|
||||
let (pw, ph) = (p.width_mm, p.height_mm);
|
||||
if (pw - w).abs() <= FRAME_TOL_MM && (ph - h).abs() <= FRAME_TOL_MM {
|
||||
Some((p.name, false))
|
||||
} else if (ph - w).abs() <= FRAME_TOL_MM && (pw - h).abs() <= FRAME_TOL_MM {
|
||||
Some((p.name, true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Sheet frames grouped into books: rows of equal lower edge, bottom to top, each left to right.
|
||||
fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, usize) {
|
||||
let mut found: Vec<(i64, f64, Frame)> = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for e in d.model.iter().take(MAX_FRAMES) {
|
||||
let EntityKind::LwPolyline(pl) = &e.kind else { continue };
|
||||
if !pl.closed {
|
||||
continue;
|
||||
}
|
||||
let b = cadcraft_doc::entity_bounds(d, e, 0);
|
||||
if b.is_empty() || !b.width().is_finite() || !b.height().is_finite() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (b.width() * unit, b.height() * unit);
|
||||
if (h - frame_height).abs() > 0.0005 {
|
||||
continue;
|
||||
}
|
||||
match paper_for(w, h) {
|
||||
Some((paper, landscape)) => {
|
||||
let row = (b.min.y * 1000.0).round();
|
||||
if row.is_finite() && row.abs() < 9e15 {
|
||||
found.push((row as i64, b.min.x, Frame { window: b, paper, landscape }));
|
||||
}
|
||||
}
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut books: Vec<Vec<Frame>> = Vec::new();
|
||||
let mut last = None;
|
||||
for (row, _, f) in found {
|
||||
if last != Some(row) {
|
||||
books.push(Vec::new());
|
||||
last = Some(row);
|
||||
}
|
||||
if let Some(b) = books.last_mut() {
|
||||
b.push(f);
|
||||
}
|
||||
}
|
||||
(books, skipped)
|
||||
}
|
||||
|
||||
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return Err(bad("pdfbook", "plotting is not available in this build"));
|
||||
}
|
||||
let num = |k: &str, default: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(default);
|
||||
let (frame_height, unit) = (num("frameHeight", 297.0), num("unit", 1.0));
|
||||
let (books, skipped) = frame_books(s.doc()?, frame_height, unit);
|
||||
if books.is_empty() {
|
||||
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
|
||||
}
|
||||
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
|
||||
let pb = target.as_deref().map(std::path::Path::new);
|
||||
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let stem = pb
|
||||
.and_then(std::path::Path::file_stem)
|
||||
.map(|x| x.to_string_lossy().to_string())
|
||||
.filter(|x| !x.is_empty())
|
||||
.unwrap_or_else(|| "Drawing".into());
|
||||
let names: Vec<String> = match p.get("names").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect(),
|
||||
None => stem.split('^').map(str::to_string).collect(),
|
||||
};
|
||||
let write = pb.is_some();
|
||||
let mut out = Vec::new();
|
||||
for (i, frames) in books.iter().enumerate() {
|
||||
// A row without its own name is numbered after the drawing.
|
||||
let name = names.get(i).map(|n| file_part(n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{}-{}", file_part(&stem), i + 1));
|
||||
let pages: Vec<Value> = frames
|
||||
.iter()
|
||||
.map(|f| json!({ "window": [[f.window.min.x, f.window.min.y], [f.window.max.x, f.window.max.y]], "paper": f.paper, "landscape": f.landscape }))
|
||||
.collect();
|
||||
let mut q = json!({ "layout": "Model", "pages": pages, "margins": [0, 0, 0, 0] });
|
||||
if write {
|
||||
q["path"] = json!(dir.join(format!("{name}.pdf")).to_string_lossy());
|
||||
}
|
||||
let mut r = s.execute("plot", &q).map_err(|e| bad("pdfbook", format!("{name}: {e}")))?;
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("name".into(), json!(name));
|
||||
o.insert("pages".into(), json!(frames.len()));
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
let message = format!("Plotted {} book(s), {} sheet(s)", out.len(), books.iter().map(Vec::len).sum::<usize>());
|
||||
Ok(json!({ "books": out, "skipped": skipped, "message": message }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cadcraft_doc::{Block, Circle, Common, Handle, Insert, Layer, Line};
|
||||
@@ -587,6 +704,39 @@ mod tests {
|
||||
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfbook_collects_frames_into_one_pdf_per_row() {
|
||||
our_hooks();
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// Row 1 (y = 0): A4, A3 landscape (drawn right to left); row 2 (y = 400): one A4; a stray rectangle.
|
||||
for c in ["pline 210,0 420,0 420,297 210,297 c", "pline 0,0 210,0 210,297 0,297 c", "pline 420,0 840,0 840,297 420,297 c"] {
|
||||
s.cmdline(c).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
s.cmdline("pline 0,400 210,400 210,697 0,697 c").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() }));
|
||||
let r = r.unwrap();
|
||||
let books = r["books"].as_array().unwrap();
|
||||
assert_eq!(books.len(), 2);
|
||||
assert_eq!(books[0]["pages"], 3);
|
||||
assert_eq!(books[1]["pages"], 1);
|
||||
let first = temp("a.pdf");
|
||||
let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();
|
||||
let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());
|
||||
assert!(a4 < a3, "frames go left to right: {text}");
|
||||
assert!(text.contains("\"landscape\":true"));
|
||||
let _ = std::fs::remove_file(first);
|
||||
let _ = std::fs::remove_file(temp("b.pdf"));
|
||||
// No frames: an error, not a panic.
|
||||
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_plots_every_sheet() {
|
||||
our_hooks();
|
||||
|
||||
+238
-18
@@ -12,8 +12,10 @@ pub use cadcraft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
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};
|
||||
|
||||
@@ -37,6 +39,24 @@ pub fn io() -> Option<&'static IoHooks> {
|
||||
IO.get()
|
||||
}
|
||||
|
||||
/// An entity as DXF groups and back, for AutoLISP's `entget`, `entmake` and `entmod`
|
||||
/// (installed by the app with the DXF reader and writer, a lower layer).
|
||||
pub struct EntityCodec {
|
||||
/// The entity's type name and the groups of its record, without its `0` group.
|
||||
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
|
||||
/// An entity from a type name and groups.
|
||||
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
}
|
||||
|
||||
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
|
||||
|
||||
pub fn set_entity_codec(c: EntityCodec) {
|
||||
let _ = CODEC.set(c);
|
||||
}
|
||||
pub fn entity_codec() -> Option<&'static EntityCodec> {
|
||||
CODEC.get()
|
||||
}
|
||||
|
||||
/// COMPARE (drawing comparison).
|
||||
pub mod compare;
|
||||
|
||||
@@ -117,11 +137,13 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
|
||||
if opts.binary && !is_dxf(path) {
|
||||
return Err(bad(cmd, "binary applies to DXF files only"));
|
||||
}
|
||||
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
|
||||
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
|
||||
let r = if *opts == SaveAs::default() {
|
||||
(hooks.write)(s.doc()?, path)
|
||||
(hooks.write)(d, path)
|
||||
} else {
|
||||
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
|
||||
w(s.doc()?, path, opts)
|
||||
w(d, path, opts)
|
||||
};
|
||||
r.map_err(|e| bad(cmd, e))
|
||||
}
|
||||
@@ -138,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("open", "Open...", run_open)
|
||||
.menu(&["File", "Open..."])
|
||||
.key("Cmd+O")
|
||||
.params("{path} | {data: base64, name}")
|
||||
.params("{path} | {data: base64, name} → {index, entities?, warnings: what reading left out}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
|
||||
@@ -153,9 +175,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
|
||||
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
|
||||
.noundo(),
|
||||
CommandSpec::new("script", "Run Script...", run_script)
|
||||
.menu(&["Tools", "Run Script..."])
|
||||
.alias(&["scr"])
|
||||
.params("{path: script (.scr) file} | {data: base64} → {lines}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(ScriptM))),
|
||||
]
|
||||
}
|
||||
|
||||
/// SCRIPT: run the lines of a `.scr` file through [`Session::script`] — the same execution the
|
||||
/// CLI, MCP and the control channel already use, now reachable from the command line and menus.
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = if let Some(_path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let path = _path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad("script", "paths are not available on the web; pass `data`"));
|
||||
} else {
|
||||
let data = str_param(p, "data").ok_or_else(|| bad("script", "`path` or `data` (base64) is required"))?;
|
||||
let bytes = base64_decode(data).ok_or_else(|| bad("script", "invalid base64"))?;
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
};
|
||||
let lines = text.lines().count();
|
||||
s.script(&text)?;
|
||||
Ok(json!({ "lines": lines }))
|
||||
}
|
||||
|
||||
/// SCRIPT typed at the command line or chosen from the menu: asks for the script file by name
|
||||
/// (the prompt form; the menu item runs this same machine).
|
||||
struct ScriptM;
|
||||
|
||||
impl Interactive for ScriptM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SCRIPT"
|
||||
}
|
||||
fn prompt(&self, _: &Session) -> Prompt {
|
||||
Prompt::new("Enter name of script file", Accept::TEXT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if matches!(i, Input::Cancel | Input::Enter) {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let Input::Text(t) = i else { return Ok(Step::Continue) };
|
||||
let t = t.trim();
|
||||
if t.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
match run_script(s, &json!({ "path": t })) {
|
||||
Ok(_) => Ok(Step::Done),
|
||||
Err(EngineError::BadParams { msg, .. }) => {
|
||||
s.echo(msg);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = template::run_new(s, p)? {
|
||||
return Ok(r);
|
||||
@@ -168,15 +250,80 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
||||
/// A file read and decoded but not yet added to a [`Session`]. Reading is the slow part of OPEN and
|
||||
/// needs no session, so a host can do it on a worker thread and hand the result to
|
||||
/// [`finish_open`] on the thread that owns the session.
|
||||
pub struct ReadFile {
|
||||
drawing: Drawing,
|
||||
binary_dxf: bool,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// Read and decode `path` (the slow half of `OPEN {path}`). Needs no session; runs anywhere.
|
||||
pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
read_bytes(&bytes, path, Some(path.to_string()))
|
||||
});
|
||||
match r {
|
||||
Ok(r) => r.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(bad("open", format!("{path}: the file could not be read")).to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
Err(bad("open", "paths are not available on the web; pass `data`").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a [`read_file`] result to the session as the active drawing: the fast half of OPEN.
|
||||
pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
|
||||
let ReadFile { drawing, binary_dxf, name, path } = f;
|
||||
let i = add_read(s, drawing, binary_dxf, &name, path);
|
||||
super::blocks::xref::after_open(s, i);
|
||||
Ok(opened(s, i, true))
|
||||
}
|
||||
|
||||
fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile> {
|
||||
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
||||
let i = s.open_drawing(d, &file_name(name), path);
|
||||
let drawing = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
||||
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
||||
// asks where to save instead of exporting over the original.
|
||||
let path = path.filter(|p| is_drawing_file(p));
|
||||
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
}
|
||||
|
||||
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
|
||||
let notes = drawing.read_notes.clone();
|
||||
let i = s.open_drawing(drawing, &file_name(name), path);
|
||||
if let Some(st) = s.docs.get_mut(i) {
|
||||
// QSAVE keeps a binary DXF binary.
|
||||
st.binary_dxf = cadcraft_dxf::is_binary(bytes);
|
||||
st.binary_dxf = binary_dxf;
|
||||
}
|
||||
for note in notes {
|
||||
s.echo(note);
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
||||
let ReadFile { drawing, binary_dxf, name, path } = read_bytes(bytes, name, path)?;
|
||||
Ok(add_read(s, drawing, binary_dxf, &name, path))
|
||||
}
|
||||
|
||||
/// OPEN's reply: the drawing's index and what reading it left out.
|
||||
fn opened(s: &Session, i: usize, entities: bool) -> Value {
|
||||
let d = s.docs.get(i).map(|st| &st.doc);
|
||||
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
|
||||
if entities {
|
||||
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
|
||||
} else {
|
||||
json!({ "index": i, "warnings": warnings })
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -188,7 +335,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
return Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }));
|
||||
return Ok(opened(s, i, true));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad("open", "paths are not available on the web; pass `data`"));
|
||||
@@ -198,7 +345,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
|
||||
let i = open_bytes(s, &bytes, name, None)?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
Ok(json!({ "index": i }))
|
||||
Ok(opened(s, i, false))
|
||||
}
|
||||
|
||||
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -233,16 +380,19 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
/// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
|
||||
/// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
|
||||
fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
|
||||
if s.doc()?.refedit.is_some() {
|
||||
return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
|
||||
}
|
||||
let bytes = write_bytes(s, cmd, path, opts)?;
|
||||
let n = commit_save(s, path, &bytes, store)?;
|
||||
if is_drawing_file(path) {
|
||||
let st = s.state_mut()?;
|
||||
st.binary_dxf = opts.binary;
|
||||
if let Some(v) = opts.version
|
||||
&& st.doc.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
||||
&& st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
||||
{
|
||||
std::sync::Arc::make_mut(&mut st.doc).header.set_str("ACADVER", v.code());
|
||||
st.saved = st.doc.clone();
|
||||
std::sync::Arc::make_mut(st.drawing_arc_mut()).header.set_str("ACADVER", v.code());
|
||||
st.saved = st.drawing().clone();
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
@@ -278,7 +428,7 @@ fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&st
|
||||
return Ok(bytes.len());
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
st.saved = st.doc.clone();
|
||||
st.saved = st.drawing().clone();
|
||||
st.path = Some(path.to_string());
|
||||
st.title = file_name(path);
|
||||
// A BEgin group still open when the drawing is saved ends there.
|
||||
@@ -337,7 +487,8 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fmt = str_param(p, "format").unwrap_or("dxf");
|
||||
let opts = save_options("document.bytes", p)?;
|
||||
let lost = not_saved(s.doc()?);
|
||||
let version = opts.version.unwrap_or_else(|| drawing_version(s, &format!("drawing.{fmt}")));
|
||||
let lost = not_saved_as(s.doc()?, version);
|
||||
let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?;
|
||||
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
|
||||
if drops_unknown(&format!("drawing.{fmt}")) {
|
||||
@@ -348,12 +499,28 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model
|
||||
/// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but
|
||||
/// not written), counted by DXF type across model space, layouts and block definitions.
|
||||
/// not written), counted by DXF type across model space, layouts and block definitions. Saved
|
||||
/// at the drawing's own version; see [`not_saved_as`].
|
||||
pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
|
||||
let v = d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default();
|
||||
not_saved_as(d, v)
|
||||
}
|
||||
|
||||
/// Solid modeler entities whose ACIS geometry a file of AutoCAD 2013 and later keeps in its data
|
||||
/// store section, which is written back with the drawing (`Foreign::data_store`).
|
||||
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
|
||||
|
||||
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
|
||||
/// store has a record for keeps its geometry and is not counted.
|
||||
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
|
||||
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
|
||||
continue;
|
||||
}
|
||||
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
@@ -394,7 +561,10 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
|
||||
|
||||
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
|
||||
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
|
||||
let lost = s.doc().map(not_saved).unwrap_or_default();
|
||||
// What the file will hold depends on the version it is saved as (the data store from 2013).
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
|
||||
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
|
||||
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
|
||||
let mut r = run(s, p)?;
|
||||
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
|
||||
report_not_saved(s, &mut r, &lost);
|
||||
@@ -462,6 +632,56 @@ mod tests {
|
||||
s.state().unwrap().title.clone()
|
||||
}
|
||||
|
||||
fn temp_script(name: &str, text: &str) -> String {
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_command_runs_a_scr_file() {
|
||||
let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("script", &json!({ "path": path })).unwrap();
|
||||
assert_eq!(r["lines"], 3);
|
||||
assert_eq!(s.state().unwrap().doc.model.len(), 2, "the comment is skipped, circle and rectang draw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_command_reports_a_missing_file() {
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script", &json!({ "path": "no/such/script.scr" })).unwrap_err().to_string();
|
||||
assert!(e.contains("no/such/script.scr"), "{e}");
|
||||
assert_eq!(s.state().unwrap().doc.model.len(), 0);
|
||||
}
|
||||
|
||||
/// A script that runs itself must be refused, not recurse until the stack dies.
|
||||
#[test]
|
||||
fn a_script_cannot_start_a_script() {
|
||||
let inner = temp_script("inner.scr", "circle 0,0 1\n");
|
||||
let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
|
||||
assert!(e.contains("already running"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
|
||||
let good = temp_script("typed.scr", "circle 0,0 1\n");
|
||||
let mut s = Session::new();
|
||||
s.cmdline("script").unwrap();
|
||||
assert!(s.running.is_some(), "SCRIPT asks for the file name");
|
||||
s.cmdline(&good).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.state().unwrap().doc.model.len(), 1);
|
||||
|
||||
// A file that cannot be read: the error is echoed, the command still ends.
|
||||
s.cmdline("script").unwrap();
|
||||
s.cmdline("no/such/script.scr").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("no/such/script.scr")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_inactive_drawing_keeps_active_drawing() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+250
-17
@@ -1,6 +1,7 @@
|
||||
//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects.
|
||||
|
||||
mod patfile;
|
||||
pub use patfile::{PatternChoice, pattern_choices};
|
||||
pub(super) use patfile::{lines_for, run_patterns};
|
||||
|
||||
use cadcraft_color::Color;
|
||||
@@ -10,19 +11,26 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::lwpoly;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
mod assoc;
|
||||
mod edit;
|
||||
mod pick;
|
||||
pub(crate) use assoc::update as refill_associative;
|
||||
pub use pick::{HatchPicks, PickMode};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? | handles?, color1?, color2?, angle?}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
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?, 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)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(pick::HatchPickM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -249,6 +257,17 @@ pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result<Vec<Hatch
|
||||
Ok(loops)
|
||||
}
|
||||
|
||||
/// Gradient fills by name: the shapes the renderer draws (`render/src/hatch.rs`); the INV forms
|
||||
/// swap the two colours.
|
||||
pub(crate) const GRADIENT_NAMES: [&str; 10] =
|
||||
["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL", "INVLINEAR", "INVCYLINDER", "INVCURVED", "INVSPHERICAL", "INVHEMISPHERICAL"];
|
||||
|
||||
/// A gradient name as stored (upper case), or None when it isn't one of [`GRADIENT_NAMES`].
|
||||
pub(crate) fn gradient_name(t: &str) -> Option<&'static str> {
|
||||
let up = t.trim().to_ascii_uppercase();
|
||||
GRADIENT_NAMES.iter().copied().find(|n| *n == up)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HatchSettings {
|
||||
pattern: String,
|
||||
@@ -272,11 +291,11 @@ impl HatchSettings {
|
||||
));
|
||||
let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0);
|
||||
let g = gradient.then(|| Gradient {
|
||||
name: str_param(p, "name").unwrap_or("LINEAR").to_string(),
|
||||
name: str_param(p, "name").and_then(gradient_name).unwrap_or("LINEAR").to_string(),
|
||||
color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)),
|
||||
color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)),
|
||||
angle: f64_or(p, "angle", 0.0).to_radians(),
|
||||
centered: true,
|
||||
centered: bool_or(p, "centered", true),
|
||||
});
|
||||
HatchSettings {
|
||||
pattern,
|
||||
@@ -332,14 +351,21 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||||
if str_param(p, "originMode").is_some_and(|t| OriginMode::parse(t).is_none()) {
|
||||
return Err(bad(cmd, "unknown `originMode`"));
|
||||
}
|
||||
if gradient && str_param(p, "name").is_some_and(|t| gradient_name(t).is_none()) {
|
||||
return Err(bad(cmd, format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))));
|
||||
}
|
||||
let st = HatchSettings::from(s, p, gradient);
|
||||
// Loops by picked point or selected object: one hatch each when separate.
|
||||
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||||
if let Some(pts) = points_param(p, "points") {
|
||||
for q in pts {
|
||||
groups.push(loops_at(s, q)?);
|
||||
}
|
||||
} else {
|
||||
// Internal points and boundary objects can come together (the dialog's Pick points and
|
||||
// Select objects); points alone don't fall back to the selection.
|
||||
let pts = points_param(p, "points");
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
let objects = pts.is_none() || given("handles") || given("handle");
|
||||
for q in pts.into_iter().flatten() {
|
||||
groups.push(loops_at(s, q)?);
|
||||
}
|
||||
if objects {
|
||||
let hs = targets(s, p)?;
|
||||
if st.separate {
|
||||
groups.extend(loops_from_handles(s, &hs)?.into_iter().map(|l| vec![HatchLoop { outer: true, ..l }]));
|
||||
@@ -355,9 +381,63 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||||
Ok(json!({ "handle": hex.first(), "handles": hex }))
|
||||
}
|
||||
|
||||
/// True when two loops have the same vertex sequence (ignoring cyclic shift and winding order).
|
||||
fn same_loop(a: &HatchLoop, b: &HatchLoop) -> bool {
|
||||
if a.vertices.len() != b.vertices.len() {
|
||||
return false;
|
||||
}
|
||||
let n = a.vertices.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let near = |v1: &PolyVertex, v2: &PolyVertex| v1.p.near(v2.p, 1e-6) && (v1.bulge - v2.bulge).abs() < 1e-6;
|
||||
(0..n).any(|k| (0..n).all(|i| near(&a.vertices[i], &b.vertices[(i + k) % n])))
|
||||
|| (0..n).any(|k| {
|
||||
(0..n).all(|i| {
|
||||
let j = (k + n - (i % n)) % n;
|
||||
let prev_j = (j + n - 1) % n;
|
||||
a.vertices[i].p.near(b.vertices[j].p, 1e-6) && (a.vertices[i].bulge + b.vertices[prev_j].bulge).abs() < 1e-6
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Deduplicate boundary loops that describe the same boundary.
|
||||
fn dedup_loops(loops: &mut Vec<HatchLoop>) {
|
||||
let mut unique: Vec<HatchLoop> = Vec::with_capacity(loops.len());
|
||||
for l in loops.drain(..) {
|
||||
if let Some(existing) = unique.iter_mut().find(|u| same_loop(u, &l)) {
|
||||
for s in l.sources {
|
||||
if !existing.sources.contains(&s) {
|
||||
existing.sources.push(s);
|
||||
}
|
||||
}
|
||||
existing.outer = existing.outer || l.outer;
|
||||
} else {
|
||||
unique.push(l);
|
||||
}
|
||||
}
|
||||
*loops = unique;
|
||||
}
|
||||
|
||||
/// Add the hatches for groups of loops: one per group when separate, otherwise one for all.
|
||||
fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>) -> Result<Vec<Handle>> {
|
||||
let groups: Vec<Vec<HatchLoop>> = if st.separate { groups } else { vec![groups.into_iter().flatten().collect()] };
|
||||
let groups: Vec<Vec<HatchLoop>> = if st.separate {
|
||||
let mut unique_groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||||
for mut g in groups {
|
||||
dedup_loops(&mut g);
|
||||
if g.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !unique_groups.iter().any(|u| u.len() == g.len() && u.iter().zip(&g).all(|(a, b)| same_loop(a, b))) {
|
||||
unique_groups.push(g);
|
||||
}
|
||||
}
|
||||
unique_groups
|
||||
} else {
|
||||
let mut all: Vec<HatchLoop> = groups.into_iter().flatten().collect();
|
||||
dedup_loops(&mut all);
|
||||
vec![all]
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for loops in groups.into_iter().filter(|g| !g.is_empty()) {
|
||||
let h = s.add_entity(st.hatch(loops))?;
|
||||
@@ -373,6 +453,13 @@ fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>)
|
||||
|
||||
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
patfile::load_param(p)?;
|
||||
// Like the prompt: a name without a definition would be drawn as ANSI31.
|
||||
if let Some(up) = str_param(p, "pattern").map(str::to_ascii_uppercase)
|
||||
&& cadcraft_doc::library::pattern(&up).is_none()
|
||||
&& patfile::user_pattern(&up).is_none()
|
||||
{
|
||||
return Err(bad("hatch", format!("unknown pattern {up}")));
|
||||
}
|
||||
make(s, p, false)
|
||||
}
|
||||
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -388,11 +475,27 @@ fn boundary_at(s: &mut Session, q: Vec2) -> Result<Vec<Handle>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most internal points one BOUNDARY call accepts (checked before the list is copied).
|
||||
const MAX_BOUNDARY_POINTS: usize = 256;
|
||||
/// Most polylines one BOUNDARY call may create. Each pick adds polylines the next pick traces
|
||||
/// against, so this bounds the aggregate tracing work of the batch.
|
||||
const MAX_BOUNDARY_OUTPUT: usize = 1024;
|
||||
|
||||
fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||||
if raw.len() > MAX_BOUNDARY_POINTS {
|
||||
return Err(bad("boundary", format!("at most {MAX_BOUNDARY_POINTS} points per call (got {})", raw.len())));
|
||||
}
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||||
let before = s.state()?.doc.clone();
|
||||
let mut out = Vec::new();
|
||||
for q in pts {
|
||||
out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex()));
|
||||
if out.len() > MAX_BOUNDARY_OUTPUT {
|
||||
// The refused call leaves no partial result behind.
|
||||
s.state_mut()?.doc = before;
|
||||
return Err(bad("boundary", format!("more than {MAX_BOUNDARY_OUTPUT} boundary polylines in one call; split the work")));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
|
||||
}
|
||||
@@ -528,7 +631,11 @@ impl Interactive for HatchM {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
// The sub-prompts offer the current value: Enter keeps it.
|
||||
match self.asking {
|
||||
Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()),
|
||||
Some(HatchAsk::Pattern) => {
|
||||
return Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
.default(self.settings.pattern.clone())
|
||||
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
|
||||
}
|
||||
Some(HatchAsk::Scale) => {
|
||||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||||
}
|
||||
@@ -542,8 +649,10 @@ impl Interactive for HatchM {
|
||||
Some(HatchAsk::Origin) => return Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"),
|
||||
None => {}
|
||||
}
|
||||
// The pattern picker stays offered while boundaries are picked: a choice sets the pattern.
|
||||
let picker = |p: Prompt| if self.gradient { p } else { p.picker(Picker::HatchPattern(self.settings.pattern.clone())) };
|
||||
if self.selecting {
|
||||
return Prompt::new("Select objects", Accept::SELECT).kw(&[
|
||||
return picker(Prompt::new("Select objects", Accept::SELECT).kw(&[
|
||||
"picK internal point",
|
||||
"Undo",
|
||||
"seTtings",
|
||||
@@ -551,9 +660,9 @@ impl Interactive for HatchM {
|
||||
"Angle",
|
||||
"Origin",
|
||||
"sEparate",
|
||||
]);
|
||||
]));
|
||||
}
|
||||
Prompt::new("Pick internal point", Accept::POINT).kw(&[
|
||||
picker(Prompt::new("Pick internal point", Accept::POINT).kw(&[
|
||||
"Select objects",
|
||||
"Undo",
|
||||
"seTtings",
|
||||
@@ -562,7 +671,7 @@ impl Interactive for HatchM {
|
||||
"Angle",
|
||||
"Origin",
|
||||
"sEparate",
|
||||
])
|
||||
]))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
@@ -616,6 +725,11 @@ impl Interactive for HatchM {
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// A pattern chosen in the picker while boundaries are picked.
|
||||
Input::Text(t) if !self.gradient => {
|
||||
self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// Enter ends boundary picking in either mode and creates the hatch.
|
||||
Input::Enter => self.finish(s),
|
||||
_ => Ok(Step::Continue),
|
||||
@@ -683,4 +797,123 @@ mod tests {
|
||||
assert!(!hatch.solid);
|
||||
assert!(hatch.gradient.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatchedit_solid_replaces_gradient() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("gradient", &json!({ "points": [[5, 5]], "color1": "1", "color2": "3" })).unwrap();
|
||||
let (h, before) = only_hatch(&s);
|
||||
assert!(before.gradient.is_some());
|
||||
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "SOLID" })).unwrap();
|
||||
let (h2, hatch) = only_hatch(&s);
|
||||
assert_eq!(h2, h);
|
||||
assert!(hatch.solid);
|
||||
assert!(hatch.gradient.is_none(), "SOLID is a plain fill, not the gradient");
|
||||
assert_eq!(hatch.loops, before.loops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.start("hatch").unwrap();
|
||||
let picker = s.current_prompt().and_then(|p| p.picker);
|
||||
assert_eq!(picker, Some(Picker::HatchPattern(HatchSettings::from(&s, &Value::Null, false).pattern)));
|
||||
// A choice made in the picker while boundaries are picked.
|
||||
s.input(Input::Text("AR-CONC".into())).unwrap();
|
||||
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("AR-CONC".into())));
|
||||
// And at the pattern prompt.
|
||||
s.input(Input::Keyword("Pattern".into())).unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_some());
|
||||
s.input(Input::Text("BRICK".into())).unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
|
||||
// An unknown name keeps the pattern.
|
||||
s.start("hatch").unwrap();
|
||||
s.input(Input::Text("NO-SUCH".into())).unwrap();
|
||||
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("BRICK".into())));
|
||||
// GRADIENT offers no pattern picker.
|
||||
s.cancel();
|
||||
s.start("gradient").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_hatch_refuses_an_undefined_pattern() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "NOPE" })).unwrap_err();
|
||||
assert!(e.to_string().contains("unknown pattern NOPE"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "only the rectangle");
|
||||
for name in ["ansi31", "SOLID"] {
|
||||
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": name })).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_choices_list_the_library_with_its_lines() {
|
||||
let all = pattern_choices();
|
||||
assert_eq!(all.first().map(|c| c.name.as_str()), Some("SOLID"));
|
||||
assert!(all[0].lines.is_empty());
|
||||
let ansi31 = all.iter().find(|c| c.name == "ANSI31").unwrap();
|
||||
assert_eq!(ansi31.lines.len(), 1);
|
||||
assert!(!ansi31.description.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_picks_and_handles_in_one_region_do_not_produce_duplicate_loops() {
|
||||
// One internal point
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h1) = only_hatch(&s);
|
||||
assert_eq!(h1.loops.len(), 1);
|
||||
|
||||
// Two different points in the same rectangle
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[2, 2], [8, 8]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h2) = only_hatch(&s);
|
||||
assert_eq!(h2.loops.len(), 1, "two points in same region must deduplicate loops");
|
||||
|
||||
// The same point twice
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[5, 5], [5, 5]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h3) = only_hatch(&s);
|
||||
assert_eq!(h3.loops.len(), 1, "identical points must deduplicate loops");
|
||||
|
||||
// Three points in the same rectangle
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[2, 2], [5, 5], [8, 8]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h4) = only_hatch(&s);
|
||||
assert_eq!(h4.loops.len(), 1);
|
||||
|
||||
// Same boundary handle twice
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let handle = r["handle"].as_str().unwrap();
|
||||
s.execute("hatch", &json!({ "handles": [handle, handle], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h5) = only_hatch(&s);
|
||||
assert_eq!(h5.loops.len(), 1, "repeated boundary handle must deduplicate loops");
|
||||
|
||||
// Two points in disjoint rectangles: both regions preserved
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[5, 5], [25, 5]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h6) = only_hatch(&s);
|
||||
assert_eq!(h6.loops.len(), 2, "disjoint rectangles must both be kept");
|
||||
|
||||
// Gradient with duplicate points in the same rectangle
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("gradient", &json!({ "points": [[2, 2], [8, 8]], "color1": "1", "color2": "3" })).unwrap();
|
||||
let (_, hg) = only_hatch(&s);
|
||||
assert_eq!(hg.loops.len(), 1, "gradient with repeated picks in same region must deduplicate loops");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
//! associativity (unlink, or link to boundary objects), separate hatches and recreated
|
||||
//! boundaries of existing hatches.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Hatch, HatchLoop, entity_bounds};
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, EntityKind, Gradient, Handle, Hatch, HatchLoop, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::assoc::{detach, same_shape};
|
||||
use super::{ORIGIN_KW, OriginMode, closed_loop, 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, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
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.
|
||||
const MAX_EDITED: usize = 10_000;
|
||||
@@ -145,9 +146,10 @@ fn properties(h: &mut Hatch, p: &Value) {
|
||||
if let Some(v) = str_param(p, "pattern").map(str::trim).filter(|v| !v.is_empty()) {
|
||||
h.pattern = v.to_ascii_uppercase();
|
||||
h.solid = h.pattern == "SOLID";
|
||||
// A named pattern, SOLID included, replaces the gradient fill; the renderer draws the
|
||||
// gradient first when it is set.
|
||||
h.gradient = None;
|
||||
if !h.solid {
|
||||
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
|
||||
h.gradient = None;
|
||||
// A library pattern is drawn from the library; a loaded .pat pattern brings its lines;
|
||||
// any other name is the hatch's own and keeps its definition.
|
||||
if cadcraft_doc::library::pattern(&h.pattern).is_some() {
|
||||
@@ -165,8 +167,203 @@ fn properties(h: &mut Hatch, p: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// HATCHEDIT's `gradient`: the fields to change, all checked before any hatch is touched.
|
||||
struct GradientEdit {
|
||||
name: Option<&'static str>,
|
||||
color1: Option<Color>,
|
||||
color2: Option<Color>,
|
||||
/// Radians.
|
||||
angle: Option<f64>,
|
||||
centered: Option<bool>,
|
||||
}
|
||||
|
||||
impl GradientEdit {
|
||||
/// Make `h` a gradient fill (from the default LINEAR blue-to-white one when it has none) and
|
||||
/// set the given fields.
|
||||
fn apply(&self, h: &mut Hatch) {
|
||||
let mut g = h.gradient.clone().unwrap_or(Gradient {
|
||||
name: "LINEAR".into(),
|
||||
color1: Color::Index(5),
|
||||
color2: Color::Index(7),
|
||||
angle: 0.0,
|
||||
centered: true,
|
||||
});
|
||||
if let Some(n) = self.name {
|
||||
g.name = n.into();
|
||||
}
|
||||
if let Some(c) = self.color1 {
|
||||
g.color1 = c;
|
||||
}
|
||||
if let Some(c) = self.color2 {
|
||||
g.color2 = c;
|
||||
}
|
||||
if let Some(a) = self.angle {
|
||||
g.angle = a;
|
||||
}
|
||||
if let Some(c) = self.centered {
|
||||
g.centered = c;
|
||||
}
|
||||
h.gradient = Some(g);
|
||||
h.pattern = "SOLID".into();
|
||||
h.solid = true;
|
||||
h.pattern_lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn gradient_edit(p: &Value) -> Result<Option<GradientEdit>> {
|
||||
let g = match p.get("gradient") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::Object(g)) => g,
|
||||
Some(_) => return Err(bad("hatchedit", "`gradient` is an object {name?, color1?, color2?, angle?, centered?}")),
|
||||
};
|
||||
let field = |k: &str| g.get(k).filter(|v| !v.is_null());
|
||||
let name = match field("name") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_str()
|
||||
.and_then(gradient_name)
|
||||
.ok_or_else(|| bad("hatchedit", format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))))?,
|
||||
),
|
||||
};
|
||||
let color = |k: &str| -> Result<Option<Color>> {
|
||||
match field(k) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(Color::parse)
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad("hatchedit", format!("`gradient.{k}` is a colour such as \"5\", \"red\" or \"255,128,0\""))),
|
||||
}
|
||||
};
|
||||
let angle = match field("angle") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64().map(f64::to_radians).filter(|a| a.is_finite()).ok_or_else(|| bad("hatchedit", "`gradient.angle` is a number of degrees"))?,
|
||||
),
|
||||
};
|
||||
let centered = match field("centered") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad("hatchedit", "`gradient.centered` is true or false"))?),
|
||||
};
|
||||
Ok(Some(GradientEdit { name, color1: color("color1")?, color2: color("color2")?, angle, centered }))
|
||||
}
|
||||
|
||||
/// An explicit `scale` must be positive and an explicit `angle` a number, checked before anything
|
||||
/// is edited so a refused request changes nothing (absent and null leave them as they are).
|
||||
fn check_numbers(p: &Value) -> Result<()> {
|
||||
size_param("hatchedit", p, "scale", false)?;
|
||||
match p.get("angle") {
|
||||
None | Some(Value::Null) => Ok(()),
|
||||
Some(v) if v.as_f64().is_some_and(f64::is_finite) => Ok(()),
|
||||
Some(_) => Err(bad("hatchedit", "`angle` must be a number of degrees")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -185,6 +382,10 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Some(_) => return Err(bad("hatchedit", "`recreate` is \"polyline\"")),
|
||||
};
|
||||
let grad = gradient_edit(p)?;
|
||||
if grad.is_some() && str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).is_some_and(|v| !v.is_empty() && v != "SOLID") {
|
||||
return Err(bad("hatchedit", "give `pattern` or `gradient`, not both"));
|
||||
}
|
||||
// Like the prompt: a pattern must be one CADCraft defines, SOLID, or the hatches' own (a
|
||||
// pattern read from a file keeps its definition).
|
||||
if let Some(up) = str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty())
|
||||
@@ -209,7 +410,18 @@ 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);
|
||||
}
|
||||
if let Some(st) = style {
|
||||
h.style = st;
|
||||
}
|
||||
@@ -258,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,
|
||||
@@ -268,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 {
|
||||
@@ -320,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",
|
||||
@@ -328,7 +557,10 @@ impl Interactive for HatchEditM {
|
||||
};
|
||||
Prompt::new("Island detection style", Accept::TEXT).kw(&["Ignore", "Outer", "Normal"]).default(cur)
|
||||
}
|
||||
Ask::Pattern => Prompt::new("Enter a pattern name", Accept::TEXT).default(h.map(|h| h.pattern).unwrap_or_default()),
|
||||
Ask::Pattern => {
|
||||
let current = h.map(|h| h.pattern).unwrap_or_default();
|
||||
Prompt::new("Enter a pattern name", Accept::TEXT).default(current.clone()).picker(Picker::HatchPattern(current))
|
||||
}
|
||||
Ask::Scale => Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", h.map_or(1.0, |h| h.scale))),
|
||||
Ask::Angle => Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER)
|
||||
.default(format!("{:.0}", h.map_or(0.0, |h| h.angle.to_degrees()))),
|
||||
@@ -370,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) => {
|
||||
|
||||
@@ -44,6 +44,25 @@ pub(crate) fn names() -> Vec<String> {
|
||||
v
|
||||
}
|
||||
|
||||
/// A pattern offered for new hatches: a library pattern or one a `.pat` file loaded.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PatternChoice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// The line families at unit scale (none for SOLID).
|
||||
pub lines: Vec<PatternLine>,
|
||||
}
|
||||
|
||||
/// Every pattern offered for new hatches, in the order of [`names`], for a pattern picker.
|
||||
pub fn pattern_choices() -> Vec<PatternChoice> {
|
||||
let mut v: Vec<PatternChoice> = standard_patterns()
|
||||
.into_iter()
|
||||
.map(|p| PatternChoice { name: p.name.to_string(), description: p.description.to_string(), lines: p.lines })
|
||||
.collect();
|
||||
v.extend(store().iter().map(|p| PatternChoice { name: p.name.clone(), description: p.description.clone(), lines: p.lines.clone() }));
|
||||
v
|
||||
}
|
||||
|
||||
/// Add the patterns of a `.pat` file's text (a pattern loaded before under the same name is
|
||||
/// replaced). Returns the names added and the file's problems.
|
||||
pub(crate) fn load(text: &str) -> (Vec<String>, Vec<String>) {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! HATCHPICK: the Hatch and Gradient dialog's picks (internal points, boundary objects, a pattern
|
||||
//! origin, or the hatches to edit), kept in the session for the dialog to take. It never changes
|
||||
//! the drawing.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle, HatchLoop};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{HatchSettings, closed_loop, loops_at, loops_from_handles};
|
||||
use crate::cmd::{bad, points_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on the points and objects one HATCHPICK keeps.
|
||||
pub(crate) const MAX_PICKS: usize = 1000;
|
||||
|
||||
/// The first prompt's keywords: what to pick.
|
||||
const MODES: [&str; 4] = ["Points", "Objects", "oRigin", "Hatch"];
|
||||
|
||||
/// What HATCHPICK picks.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PickMode {
|
||||
/// Internal points, each inside a closed boundary.
|
||||
#[default]
|
||||
Points,
|
||||
/// Closed boundary objects.
|
||||
Objects,
|
||||
/// One point: the pattern origin.
|
||||
Origin,
|
||||
/// Hatches to edit.
|
||||
Hatch,
|
||||
}
|
||||
|
||||
/// What the last HATCHPICK kept ([`Session::hatch_picks`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct HatchPicks {
|
||||
pub mode: PickMode,
|
||||
pub points: Vec<Vec2>,
|
||||
/// Closed boundary objects (Objects) or hatches (Hatch).
|
||||
pub handles: Vec<Handle>,
|
||||
/// The pattern origin (Origin).
|
||||
pub origin: Option<Vec2>,
|
||||
}
|
||||
|
||||
fn mode_named(t: &str) -> Option<PickMode> {
|
||||
Some(match t.trim().to_ascii_lowercase().as_str() {
|
||||
"points" | "p" => PickMode::Points,
|
||||
"objects" | "o" => PickMode::Objects,
|
||||
"origin" | "r" => PickMode::Origin,
|
||||
"hatch" | "h" => PickMode::Hatch,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON form: `{points?, handles?}`, checked as the prompts check them.
|
||||
pub(super) fn run_hatchpick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
|
||||
if !given("points") && !given("handles") {
|
||||
return Err(bad("hatchpick", "give internal `points` or boundary `handles`"));
|
||||
}
|
||||
let mut picks = HatchPicks::default();
|
||||
if given("points") {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("hatchpick", "`points` is an array of [x, y]"))?;
|
||||
if pts.len() > MAX_PICKS {
|
||||
return Err(bad("hatchpick", format!("at most {MAX_PICKS} points")));
|
||||
}
|
||||
for q in pts {
|
||||
loops_at(s, q).map_err(|_| bad("hatchpick", format!("no closed boundary around [{}, {}]", q.x, q.y)))?;
|
||||
picks.points.push(q);
|
||||
}
|
||||
}
|
||||
if given("handles") {
|
||||
let hs = targets(s, p)?;
|
||||
if hs.len() > MAX_PICKS {
|
||||
return Err(bad("hatchpick", format!("at most {MAX_PICKS} objects")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
for h in hs {
|
||||
match d.entity(h) {
|
||||
Some(e) if closed_loop(d, e).is_some() => picks.handles.push(h),
|
||||
_ => return Err(bad("hatchpick", format!("{} is not a closed object", h.hex()))),
|
||||
}
|
||||
}
|
||||
if picks.points.is_empty() {
|
||||
picks.mode = PickMode::Objects;
|
||||
}
|
||||
}
|
||||
let out = json!({
|
||||
"points": picks.points.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>(),
|
||||
"handles": picks.handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
});
|
||||
s.hatch_picks = Some(picks);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One pick, so Undo can drop it.
|
||||
enum Pick {
|
||||
Point(Vec2, Vec<HatchLoop>),
|
||||
Objects(Vec<Handle>, Vec<HatchLoop>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct HatchPickM {
|
||||
mode: Option<PickMode>,
|
||||
picks: Vec<Pick>,
|
||||
}
|
||||
|
||||
impl HatchPickM {
|
||||
fn count(&self) -> usize {
|
||||
self.picks
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Pick::Point(..) => 1,
|
||||
Pick::Objects(hs, _) => hs.len(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
fn keep(&self, s: &mut Session, mode: PickMode) {
|
||||
let mut out = HatchPicks { mode, ..HatchPicks::default() };
|
||||
for p in &self.picks {
|
||||
match p {
|
||||
Pick::Point(q, _) => out.points.push(*q),
|
||||
Pick::Objects(hs, _) => out.handles.extend(hs.iter().copied()),
|
||||
}
|
||||
}
|
||||
s.hatch_picks = Some(out);
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for HatchPickM {
|
||||
fn name(&self) -> &'static str {
|
||||
"HATCHPICK"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.mode {
|
||||
None => Prompt::new("Enter what to pick", Accept::TEXT).kw(&MODES),
|
||||
Some(PickMode::Points) => Prompt::new("Pick internal point", Accept::POINT).kw(&["Undo"]),
|
||||
Some(PickMode::Objects) => Prompt::new("Select objects", Accept::SELECT).kw(&["Undo"]),
|
||||
Some(PickMode::Origin) => Prompt::new("Specify the pattern origin", Accept::POINT),
|
||||
Some(PickMode::Hatch) => Prompt::new("Select hatch object", Accept::SELECT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(mode) = self.mode else {
|
||||
match &i {
|
||||
Input::Keyword(t) | Input::Text(t) => self.mode = mode_named(t),
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
match (mode, i) {
|
||||
(_, Input::Cancel) => Ok(Step::Cancel),
|
||||
(PickMode::Points | PickMode::Objects, Input::Keyword(k)) if k == "Undo" => {
|
||||
if self.picks.pop().is_none() {
|
||||
s.echo("Nothing to undo.");
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(PickMode::Points | PickMode::Objects, Input::Enter) => {
|
||||
self.keep(s, mode);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(PickMode::Points, Input::Point(q)) => {
|
||||
if self.count() >= MAX_PICKS {
|
||||
s.echo(format!("At most {MAX_PICKS} picks."));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match loops_at(s, q) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
|
||||
self.picks.push(Pick::Point(q, l));
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(PickMode::Objects, Input::Pick(hs)) => {
|
||||
let room = MAX_PICKS.saturating_sub(self.count());
|
||||
let closed: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| closed_loop(d, e).is_some())).take(room).collect()
|
||||
};
|
||||
let others = hs.len().saturating_sub(closed.len());
|
||||
if !closed.is_empty() {
|
||||
let loops = loops_from_handles(s, &closed)?;
|
||||
self.picks.push(Pick::Objects(closed.clone(), loops));
|
||||
}
|
||||
s.echo(if others == 0 { format!("{} found", closed.len()) } else { format!("{} found, {others} not closed", closed.len()) });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(PickMode::Origin, Input::Point(q)) => {
|
||||
s.hatch_picks = Some(HatchPicks { mode, origin: Some(q), ..HatchPicks::default() });
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(PickMode::Hatch, Input::Pick(hs)) => {
|
||||
let hatches: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).take(MAX_PICKS).collect()
|
||||
};
|
||||
if hatches.is_empty() {
|
||||
s.echo("No hatch selected.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
s.hatch_picks = Some(HatchPicks { mode, handles: hatches, ..HatchPicks::default() });
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(PickMode::Origin | PickMode::Hatch, Input::Enter) => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, _c: Vec2) -> Vec<EntityKind> {
|
||||
let loops: Vec<HatchLoop> = self
|
||||
.picks
|
||||
.iter()
|
||||
.flat_map(|p| match p {
|
||||
Pick::Point(_, l) | Pick::Objects(_, l) => l.iter().cloned(),
|
||||
})
|
||||
.collect();
|
||||
if loops.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![HatchSettings::from(s, &Value::Null, false).hatch(loops)]
|
||||
}
|
||||
}
|
||||
@@ -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,7 +42,11 @@ 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(),
|
||||
CommandSpec::new("countlist", "Count List", count::run_countlist)
|
||||
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
|
||||
@@ -68,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 {
|
||||
@@ -109,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)
|
||||
@@ -136,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)
|
||||
@@ -150,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)
|
||||
@@ -180,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();
|
||||
@@ -202,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 };
|
||||
@@ -218,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.
|
||||
@@ -283,7 +389,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"title": st.title,
|
||||
"path": st.path,
|
||||
"dirty": st.is_dirty(),
|
||||
"space": match &space { cadcraft_doc::Space::Model => "Model".to_string(), cadcraft_doc::Space::Paper(n) => n.clone() },
|
||||
"blockEditor": st.block_editor(),
|
||||
"space": space.tab_name(),
|
||||
"entityCount": d.entity_count(),
|
||||
"counts": counts,
|
||||
"extents": if ext.is_empty() { Value::Null } else { json!([[ext.min.x, ext.min.y], [ext.max.x, ext.max.y]]) },
|
||||
@@ -297,6 +404,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
|
||||
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
|
||||
// Records of the file the drawing came from that the reader could not place.
|
||||
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
|
||||
});
|
||||
if bool_or(p, "entities", true)
|
||||
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
|
||||
@@ -329,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)))
|
||||
@@ -346,10 +458,82 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
|
||||
}
|
||||
|
||||
/// TEXTS: the readable text of the space's texts, multiline texts, block attributes and
|
||||
/// multileaders, with where they are, filtered by content, layer and a window around their
|
||||
/// insertion points. Read-only (FIND selects and can replace; this only reports).
|
||||
fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "texts";
|
||||
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_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 contains = text("contains")?;
|
||||
let layer = text("layer")?;
|
||||
let window = given("window")
|
||||
.map(|w| {
|
||||
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
|
||||
match corners.as_deref() {
|
||||
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
|
||||
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
|
||||
}
|
||||
})
|
||||
.transpose()?;
|
||||
let limit = count("limit", 200)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let st = s.state()?;
|
||||
let Some(store) = st.doc.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "offset": offset, "texts": [] })) };
|
||||
let mut found: Vec<Value> = Vec::new();
|
||||
for e in store.iter() {
|
||||
if layer.as_ref().is_some_and(|l| e.common.layer.to_lowercase() != *l) {
|
||||
continue;
|
||||
}
|
||||
let mut push = |ty: &str, value: String, at: cadcraft_geom::Vec3, height: f64, rotation: f64, style: &str, tag: Option<&str>| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty()
|
||||
|| contains.as_ref().is_some_and(|c| !value.to_lowercase().contains(c.as_str()))
|
||||
|| window.as_ref().is_some_and(|w| !w.contains(Vec2::new(at.x, at.y)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut v = json!({
|
||||
"handle": e.handle.hex(), "type": ty, "layer": e.common.layer, "text": value,
|
||||
"at": [at.x, at.y], "height": height, "rotation": rotation.to_degrees(), "style": style,
|
||||
});
|
||||
if let (Some(t), Some(o)) = (tag, v.as_object_mut()) {
|
||||
o.insert("tag".into(), json!(t));
|
||||
}
|
||||
found.push(v);
|
||||
};
|
||||
match &e.kind {
|
||||
EntityKind::Text(t) => push("Text", t.value.clone(), t.insert, t.height, t.rotation, &t.style, None),
|
||||
EntityKind::MText(t) => push("MText", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None),
|
||||
EntityKind::Insert(i) => {
|
||||
for a in i.attribs.iter().filter(|a| !a.invisible) {
|
||||
push("Attribute", a.text.value.clone(), a.text.insert, a.text.height, a.text.rotation, &a.text.style, Some(&a.tag));
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &m.text {
|
||||
push("MLeader", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let n = found.len();
|
||||
let page: Vec<Value> = found.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(json!({ "count": n, "returned": page.len(), "offset": offset, "texts": page }))
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -475,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());
|
||||
}
|
||||
@@ -522,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)
|
||||
@@ -584,7 +769,7 @@ enum AreaMode {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AreaM {
|
||||
pub(super) struct AreaM {
|
||||
pts: Vec<Vec2>,
|
||||
mode: AreaMode,
|
||||
total: f64,
|
||||
|
||||
@@ -74,10 +74,7 @@ impl Scope {
|
||||
}
|
||||
/// The top-level objects in scope, each with the name of its space.
|
||||
fn objects<'a>(&self, d: &'a Drawing) -> Vec<(String, &'a std::sync::Arc<Entity>)> {
|
||||
let name = |sp: &Space| match sp {
|
||||
Space::Model => "Model".to_string(),
|
||||
Space::Paper(n) => n.clone(),
|
||||
};
|
||||
let name = |sp: &Space| sp.tab_name().to_string();
|
||||
let mut out = Vec::new();
|
||||
let mut add = |sp: &Space| {
|
||||
if let Some(store) = d.space(sp) {
|
||||
|
||||
@@ -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?}")
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("mview", "Viewports", run_mview)
|
||||
.alias(&["mv"])
|
||||
.params(
|
||||
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), layout?}",
|
||||
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), name? (restore: a model space viewport configuration, default *Active; its viewports fill p1-p2 or the printable area), layout?}",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
|
||||
CommandSpec::new("mview.polygonal", "Polygonal Viewport", |s, p| run_mview(s, &viewports::with(p.clone(), "option", json!("polygonal"))))
|
||||
@@ -74,17 +74,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)
|
||||
.menu(&["File", "Page Setup Manager..."])
|
||||
.params(
|
||||
"{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}",
|
||||
"{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool, plotTransparency?: bool}",
|
||||
),
|
||||
CommandSpec::new("plot", "Print...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "plot")?, "plot"))
|
||||
.menu(&["File", "Print..."])
|
||||
.key("Cmd+P")
|
||||
.alias(&["print"])
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.noundo(),
|
||||
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
|
||||
.menu(&["File", "Export to PDF..."])
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, lineweights?, plotArea?, window?} (as PLOT)")
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
|
||||
.noundo(),
|
||||
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
|
||||
.alias(&["ms"])
|
||||
@@ -136,6 +136,7 @@ fn run_mspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
st.mspace = Some(pick);
|
||||
st.selection.clear();
|
||||
s.pending_window = None;
|
||||
s.echo("MSPACE");
|
||||
Ok(json!({ "viewport": pick.hex() }))
|
||||
}
|
||||
@@ -147,6 +148,7 @@ fn run_pspace(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
st.mspace = None;
|
||||
st.selection.clear();
|
||||
s.pending_window = None;
|
||||
Ok(json!({ "space": "paper" }))
|
||||
}
|
||||
|
||||
@@ -260,10 +262,10 @@ fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = match (str_param(p, "from"), s.layout_space()) {
|
||||
let from = match (str_param(p, "from"), s.layout_space().layout()) {
|
||||
(Some(f), _) => f.to_string(),
|
||||
(None, Space::Paper(n)) => n,
|
||||
(None, Space::Model) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
|
||||
(None, Some(n)) => n.to_string(),
|
||||
(None, None) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
|
||||
};
|
||||
let from = layout_name("layout.rename", s.doc()?, &from)?;
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
|
||||
@@ -634,12 +636,12 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"center",
|
||||
"plotStyleTable",
|
||||
"displayPlotStyles",
|
||||
"plotTransparency",
|
||||
];
|
||||
let changes = keys.iter().any(|k| p.get(k).is_some());
|
||||
let name = match (str_param(p, "layout"), s.space()) {
|
||||
(Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?),
|
||||
(None, Space::Paper(n)) => Some(n),
|
||||
(None, Space::Model) => None,
|
||||
let name = match str_param(p, "layout") {
|
||||
Some(n) => Some(layout_name(cmd, s.doc()?, n)?),
|
||||
None => s.space().layout().map(str::to_string),
|
||||
};
|
||||
let Some(name) = name else {
|
||||
if changes {
|
||||
@@ -716,6 +718,9 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("displayPlotStyles").and_then(Value::as_bool) {
|
||||
page.show_plot_styles = v;
|
||||
}
|
||||
if let Some(v) = p.get("plotTransparency").and_then(Value::as_bool) {
|
||||
page.plot_transparency = v;
|
||||
}
|
||||
if changes {
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) {
|
||||
l.page = page.clone();
|
||||
@@ -774,6 +779,28 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
None => s.space(),
|
||||
};
|
||||
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(raw) = p.get("offset") {
|
||||
let values = raw
|
||||
.as_array()
|
||||
.filter(|values| values.len() == 2)
|
||||
.ok_or_else(|| bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"))?;
|
||||
let x = values.first().and_then(Value::as_f64);
|
||||
let y = values.get(1).and_then(Value::as_f64);
|
||||
if !x.zip(y).is_some_and(|(x, y)| {
|
||||
x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
|
||||
&& y.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
|
||||
}) {
|
||||
return Err(bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"));
|
||||
}
|
||||
}
|
||||
if matches!(space, Space::Model)
|
||||
&& opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg"))
|
||||
&& opts.get("center").and_then(Value::as_bool) == Some(false)
|
||||
{
|
||||
return Err(bad(cmd, "model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot"));
|
||||
}
|
||||
if let Some(o) = opts.as_object_mut() {
|
||||
if let Some(v) = p.get("scale") {
|
||||
let k = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive: paper units per drawing unit, or a ratio such as \"1:50\""))?;
|
||||
@@ -782,12 +809,16 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
if let Some(w) = plot_window(s, p, cmd, &space)? {
|
||||
o.insert("window".into(), json!([[w.min.x, w.min.y], [w.max.x, w.max.y]]));
|
||||
}
|
||||
// Without one, the plotter uses each sheet's page setup.
|
||||
match super::plotters::plot_transparency(s, p) {
|
||||
Some(t) => o.insert("plotTransparency".into(), json!(t)),
|
||||
None => o.remove("plotTransparency"),
|
||||
};
|
||||
}
|
||||
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
|
||||
let layout = match (&sheets, &space) {
|
||||
(Some(names), _) => names.first().cloned().unwrap_or_default(),
|
||||
(None, Space::Model) => "Model".to_string(),
|
||||
(None, Space::Paper(n)) => n.clone(),
|
||||
let layout = match &sheets {
|
||||
Some(names) => names.first().cloned().unwrap_or_default(),
|
||||
None => space.tab_name().to_string(),
|
||||
};
|
||||
let pages = sheets.as_ref().map_or(1, Vec::len);
|
||||
match str_param(p, "path") {
|
||||
@@ -1055,12 +1086,20 @@ mod tests {
|
||||
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
|
||||
let r = s.execute("exportpdf", &json!({})).unwrap();
|
||||
assert_eq!(r["layout"], "Model");
|
||||
let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}");
|
||||
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
|
||||
// Plot area and a ratio scale reach the plotter as a model-space window and a number.
|
||||
let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"});
|
||||
let text = opts(s.execute("plot", &p).unwrap());
|
||||
assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}");
|
||||
let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap());
|
||||
assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}");
|
||||
for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] {
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err());
|
||||
}
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err());
|
||||
assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#));
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
|
||||
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
|
||||
|
||||
@@ -87,10 +87,7 @@ impl LayoutM {
|
||||
let pats: Vec<&str> = pat.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect();
|
||||
let pats = if pats.is_empty() { vec!["*"] } else { pats };
|
||||
let names: Vec<String> = ls.iter().filter(|l| pats.iter().any(|p| wildcard(p, &l.name))).map(|l| l.name.clone()).collect();
|
||||
let current = match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
};
|
||||
let current = s.layout_space().tab_name().to_string();
|
||||
s.echo(format!("Active Layout: {current}"));
|
||||
if names.is_empty() {
|
||||
s.echo(format!("No layout matches \"{pat}\"."));
|
||||
@@ -131,10 +128,7 @@ impl Interactive for LayoutM {
|
||||
Stage::RenameFrom => with_cur(Prompt::new("Enter layout to rename", Accept::TEXT)),
|
||||
Stage::RenameTo(_) => Prompt::new("Enter new layout name", Accept::TEXT),
|
||||
Stage::Set => {
|
||||
let tab = match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
};
|
||||
let tab = s.layout_space().tab_name().to_string();
|
||||
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
|
||||
}
|
||||
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
|
||||
@@ -199,10 +193,7 @@ impl Interactive for LayoutM {
|
||||
Stage::Set => {
|
||||
let n = match name {
|
||||
Some(n) => n,
|
||||
None => match s.layout_space() {
|
||||
Space::Paper(n) => n,
|
||||
Space::Model => "Model".into(),
|
||||
},
|
||||
None => s.layout_space().tab_name().to_string(),
|
||||
};
|
||||
let set = find_command("layout.set").ok_or_else(|| EngineError::UnknownCommand("layout.set".into()))?;
|
||||
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
//! Clipped viewports and MVIEW's options: polygonal and object viewports (MVIEW Polygonal /
|
||||
//! Object), VPCLIP, ON/OFF, Lock and Restore, and the typed MVIEW prompts.
|
||||
//! Object), VPCLIP, ON/OFF, Lock and Restore (a model space viewport configuration as layout
|
||||
//! viewports), and the typed MVIEW prompts.
|
||||
//!
|
||||
//! A clipped viewport keeps its rectangle as the boundary's extents and points at a paper-space
|
||||
//! entity (a closed polyline, circle, ellipse or spline) whose outline clips what it shows
|
||||
//! ([`Viewport::clip`], DXF group 340).
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport, VportConfig, VportTile};
|
||||
use cadcraft_geom::{Bounds2, PolyVertex, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::{self, handle_value};
|
||||
use super::super::helpers::{lwpoly, rect_vertices};
|
||||
use super::super::{bad, bool_or, points_param, str_param, targets};
|
||||
use super::{fit_view, layout_viewports, mview, split, target_layout};
|
||||
use super::super::{bad, bool_or, point_param, points_param, str_param, targets};
|
||||
use super::{fit_view, layout_viewports, mview, printable, split, target_layout};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most vertices a typed or JSON polygonal boundary may have.
|
||||
const MAX_VERTICES: usize = 10_000;
|
||||
/// Most viewports MVIEW Restore creates (a configuration read from a file may hold more tiles).
|
||||
const MAX_RESTORED: usize = 64;
|
||||
|
||||
/// The polygon outline's extents of a closed paper-space entity usable as a clip boundary.
|
||||
fn boundary_bounds(k: &EntityKind) -> Option<Bounds2> {
|
||||
@@ -210,6 +213,66 @@ fn mview_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>(), "locked": locked }))
|
||||
}
|
||||
|
||||
/// Where tile `t` of a viewport configuration lands when the whole configuration fills `r`; `None`
|
||||
/// for a tile with no usable area (configurations come from files).
|
||||
fn tile_rect(t: &VportTile, r: Bounds2) -> Option<Bounds2> {
|
||||
let f = |v: f64| v.is_finite().then(|| v.clamp(0.0, 1.0));
|
||||
let (x0, y0, x1, y1) = (f(t.min.x)?, f(t.min.y)?, f(t.max.x)?, f(t.max.y)?);
|
||||
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let at = |x: f64, y: f64| Vec2::new(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
Some(Bounds2::new(at(x0, y0), at(x1, y1)))
|
||||
}
|
||||
|
||||
/// The view a restored tile shows in a viewport of size `r`: its saved centre, and its height
|
||||
/// grown when the viewport is narrower than the tile was, so the whole saved view still shows.
|
||||
fn tile_view(t: &VportTile, r: Bounds2) -> Option<(Vec2, f64)> {
|
||||
if !(t.center.is_finite() && t.height.is_finite() && t.height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let aspect = r.width() / r.height().max(1e-12);
|
||||
let h = if t.aspect.is_finite() && t.aspect > 0.0 && aspect > 0.0 { t.height.max(t.height * t.aspect / aspect) } else { t.height };
|
||||
(h.is_finite() && h > 0.0).then_some((t.center, h))
|
||||
}
|
||||
|
||||
/// MVIEW Restore: `{name? (default *Active), p1?, p2? (default: the printable area), layout?}`.
|
||||
/// One viewport per tile of the model space viewport configuration, arranged as the tiles are
|
||||
/// over the window, each showing its tile's view.
|
||||
fn mview_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let space = target_layout(s, "mview", p)?;
|
||||
let Space::Paper(layout) = &space else { return Err(bad("mview", "not a layout")) };
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(VportConfig::ACTIVE).to_string();
|
||||
let tiles = super::super::view::config_tiles(s, &name)
|
||||
.ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::<String>())))?;
|
||||
let rect = match (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(Some(a), Some(b)) => Bounds2::new(a, b),
|
||||
_ => printable(s, layout)?,
|
||||
};
|
||||
if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 {
|
||||
return Err(bad("mview", "the viewport has no area"));
|
||||
}
|
||||
if rect.width() > 1e7 || rect.height() > 1e7 {
|
||||
return Err(bad("mview", "the viewport is too large"));
|
||||
}
|
||||
let common = s.current_common();
|
||||
let d = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for t in tiles.iter().take(MAX_RESTORED) {
|
||||
let Some(r) = tile_rect(t, rect) else { continue };
|
||||
let id = next_viewport_id(d);
|
||||
let mut vp = new_viewport(d, r, id);
|
||||
if let Some((c, h)) = tile_view(t, r) {
|
||||
(vp.view_center, vp.view_height) = (c, h);
|
||||
}
|
||||
out.push(d.add(&space, common.clone(), EntityKind::Viewport(vp))?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other(format!("Viewport configuration \"{name}\" has no viewports to restore.")));
|
||||
}
|
||||
Ok(json!({ "configuration": name, "viewports": handles_json(&out) }))
|
||||
}
|
||||
|
||||
/// MVIEW's options other than corners and Fit; `None` when `option` is not one of them.
|
||||
pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>> {
|
||||
let opt = str_param(p, "option")?.trim().to_ascii_lowercase();
|
||||
@@ -219,7 +282,7 @@ pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>>
|
||||
"on" => mview_on(s, p, true),
|
||||
"off" => mview_on(s, p, false),
|
||||
"lock" | "l" => mview_lock(s, p),
|
||||
"restore" | "r" => Err(bad("mview", "named viewport configurations are not available yet")),
|
||||
"restore" | "r" => mview_restore(s, p),
|
||||
"fit" | "f" | "corners" | "" => return None,
|
||||
_ => Err(bad("mview", "option must be fit, polygonal, object, on, off, lock or restore")),
|
||||
})
|
||||
@@ -471,6 +534,10 @@ enum Stage {
|
||||
Polygon(PolygonInput),
|
||||
Object,
|
||||
Lock,
|
||||
/// Restore: the configuration's name, then where its viewports go.
|
||||
RestoreName,
|
||||
RestoreCorner(String),
|
||||
RestoreOpposite(String, Vec2),
|
||||
/// ON/OFF (Some(on)) or Lock (None, with the chosen state) on picked viewports.
|
||||
Select {
|
||||
on: Option<bool>,
|
||||
@@ -507,6 +574,18 @@ impl MviewM {
|
||||
s.echo(format!("{n} viewport(s) created, zoomed to the model extents."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// MVIEW Restore with the typed name, over `rect` (None: Fit).
|
||||
fn restore(s: &mut Session, name: &str, rect: Option<Bounds2>) -> Result<Step> {
|
||||
let mut p = json!({ "option": "restore", "name": name });
|
||||
if let Some(r) = rect {
|
||||
p = with(p, "p1", json!([r.min.x, r.min.y]));
|
||||
p = with(p, "p2", json!([r.max.x, r.max.y]));
|
||||
}
|
||||
let r = mview_restore(s, &p)?;
|
||||
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
|
||||
s.echo(format!("{n} viewport(s) restored from \"{name}\"."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
fn corner_keyword(&mut self, s: &mut Session, k: &str) -> Result<Step> {
|
||||
if k == "fit" || k == "f" {
|
||||
return self.create(s, None);
|
||||
@@ -524,7 +603,7 @@ impl MviewM {
|
||||
"lock" | "l" => Stage::Lock,
|
||||
"object" | "o" => Stage::Object,
|
||||
"polygonal" | "p" => Stage::Polygon(PolygonInput::default()),
|
||||
"restore" | "r" => return Err(EngineError::Other("Named viewport configurations are not available yet.".into())),
|
||||
"restore" | "r" => Stage::RestoreName,
|
||||
_ => return Err(EngineError::Other("Requires a point or option keyword.".into())),
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
@@ -562,6 +641,9 @@ impl Interactive for MviewM {
|
||||
Stage::Polygon(pi) => pi.prompt(),
|
||||
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
|
||||
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
|
||||
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
|
||||
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
|
||||
}
|
||||
}
|
||||
@@ -653,6 +735,53 @@ impl Interactive for MviewM {
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::RestoreName => {
|
||||
let name = match &i {
|
||||
Input::Enter => VportConfig::ACTIVE.to_string(),
|
||||
Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
_ => {
|
||||
self.stage = Stage::RestoreName;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
};
|
||||
self.stage = Stage::RestoreName;
|
||||
if name == "?" {
|
||||
let names = super::super::view::config_names(s);
|
||||
s.echo(if names.is_empty() { "No saved viewport configurations.".to_string() } else { names.join(", ") });
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if super::super::view::config_tiles(s, &name).is_none() {
|
||||
return Err(EngineError::Other(format!(
|
||||
"Could not find viewport configuration \"{}\".",
|
||||
name.chars().take(64).collect::<String>()
|
||||
)));
|
||||
}
|
||||
self.stage = Stage::RestoreCorner(name);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::RestoreCorner(name) => match i {
|
||||
Input::Enter => Self::restore(s, &name, None),
|
||||
Input::Keyword(k) | Input::Text(k) if matches!(k.trim().to_ascii_lowercase().as_str(), "fit" | "f") => Self::restore(s, &name, None),
|
||||
Input::Point(a) => {
|
||||
self.stage = Stage::RestoreOpposite(name, a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) | Input::Text(_) => {
|
||||
self.stage = Stage::RestoreCorner(name);
|
||||
Err(EngineError::Other("Requires a point or option keyword.".into()))
|
||||
}
|
||||
_ => {
|
||||
self.stage = Stage::RestoreCorner(name);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Stage::RestoreOpposite(name, a) => match i {
|
||||
Input::Point(b) => Self::restore(s, &name, Some(Bounds2::new(a, b))),
|
||||
_ => {
|
||||
self.stage = Stage::RestoreOpposite(name, a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Stage::Select { on, locked, mut picked } => match i {
|
||||
Input::Enter => {
|
||||
if picked.is_empty() {
|
||||
@@ -673,12 +802,22 @@ impl Interactive for MviewM {
|
||||
},
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match &self.stage {
|
||||
Stage::Opposite(a) => {
|
||||
split(Bounds2::new(*a, c), self.count, &self.arrangement).into_iter().map(|r| lwpoly(rect_vertices(r.min, r.max), true)).collect()
|
||||
}
|
||||
Stage::Polygon(pi) => pi.preview(c),
|
||||
Stage::RestoreOpposite(name, a) => {
|
||||
let r = Bounds2::new(*a, c);
|
||||
super::super::view::config_tiles(s, name)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(MAX_RESTORED)
|
||||
.filter_map(|t| tile_rect(t, r))
|
||||
.map(|r| lwpoly(rect_vertices(r.min, r.max), true))
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! AutoLISP commands: APPLOAD loads a `.lsp` file; `lisp` evaluates LISP for programmatic
|
||||
//! callers (scripts, the control channel, MCP, the CLI).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{bad, str_param};
|
||||
use crate::lisp::value::{Value as Lv, to_text};
|
||||
use crate::{Accept, CommandSpec, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("appload", "Load Application...", run_appload)
|
||||
.menu(&["Tools", "Load Application..."])
|
||||
.alias(&["ap"])
|
||||
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(ApploadM))),
|
||||
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
|
||||
.params(
|
||||
"{code: LISP source, inputs?: [answers to get… calls: numbers, strings, [x,y] points]} → {value (printed as prin1), output: lines printed, error?}",
|
||||
)
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
|
||||
fn source(p: &Value) -> Result<(String, String)> {
|
||||
if let Some(text) = str_param(p, "text") {
|
||||
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), text.to_string()));
|
||||
}
|
||||
if let Some(data) = str_param(p, "data") {
|
||||
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
||||
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
|
||||
Ok((path.to_string(), text))
|
||||
}
|
||||
|
||||
/// The result of a blocking LISP run as JSON, with the lines it printed.
|
||||
fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String>>) -> Value {
|
||||
let output: Vec<String> = s.log.get(from_log..).map(|l| l.to_vec()).unwrap_or_default();
|
||||
match r {
|
||||
Some(Ok(v)) => json!({ "value": to_text(&v, true), "output": output }),
|
||||
Some(Err(e)) => json!({ "error": e, "output": output }),
|
||||
None => json!({ "output": output }),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, text) = source(p)?;
|
||||
let from = s.log.len();
|
||||
let r = s.lisp_load(&name, &text, true)?;
|
||||
Ok(report(s, from, r))
|
||||
}
|
||||
|
||||
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
|
||||
fn input_value(v: &Value) -> Lv {
|
||||
match v {
|
||||
Value::Array(a) if a.len() >= 2 && a.iter().all(Value::is_number) => Lv::point(&a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()),
|
||||
other => crate::lisp::drawing_from_json(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_lisp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_param(p, "code").ok_or_else(|| bad("lisp", "`code` is required"))?;
|
||||
let inputs = p.get("inputs").and_then(Value::as_array).map(|a| a.iter().map(input_value).collect()).unwrap_or_default();
|
||||
let from = s.log.len();
|
||||
let r = s.lisp_eval(code, false, true, inputs).map_err(|e| match e {
|
||||
EngineError::Other(m) => bad("lisp", m),
|
||||
e => e,
|
||||
})?;
|
||||
Ok(report(s, from, r))
|
||||
}
|
||||
|
||||
/// APPLOAD typed or chosen from the menu: asks for the file by name.
|
||||
struct ApploadM;
|
||||
|
||||
impl Interactive for ApploadM {
|
||||
fn name(&self) -> &'static str {
|
||||
"APPLOAD"
|
||||
}
|
||||
fn prompt(&self, _: &Session) -> Prompt {
|
||||
Prompt::new("Enter name of LISP file to load", Accept::TEXT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if matches!(i, Input::Cancel | Input::Enter) {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let Input::Text(t) = i else { return Ok(Step::Continue) };
|
||||
let path = t.trim().trim_matches('"').to_string();
|
||||
if path.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
match source(&json!({ "path": path })) {
|
||||
Ok((name, text)) => {
|
||||
// The routine may wait for the user: it carries on after this command ends.
|
||||
if let Err(e) = s.lisp_load(&name, &text, false) {
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(EngineError::BadParams { msg, .. }) => {
|
||||
s.echo(msg);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
//! MLEADEREDIT (add / remove leaders), MLEADERALIGN and MLEADERCOLLECT.
|
||||
//!
|
||||
//! The model has one landing per multileader: its leaders are polylines from an arrowhead to that
|
||||
//! landing, so "add leader" pushes one more arrowhead polyline and "remove leader" drops one.
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, MLeader};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("mleaderedit", "Edit Multileader", run_edit)
|
||||
.alias(&["mledit"])
|
||||
.params("{handle, mode?: add|remove (default add), points?: [[x,y]...] (arrowheads to add; with remove, points picking the leaders to remove), index?: number|[number] (remove by position)}")
|
||||
.interactive(|_| Ok(Box::new(EditM::new(None)))),
|
||||
CommandSpec::new("mleaderedit.add", "Add Leader", |s, p| run_edit(s, &with_mode(p, "add")))
|
||||
.menu(&["Modify", "Object", "Multileader", "Add Leader"])
|
||||
.params("{handle, points: [[x,y]...] (arrowhead locations)}")
|
||||
.interactive(|_| Ok(Box::new(EditM::new(Some(false))))),
|
||||
CommandSpec::new("mleaderedit.remove", "Remove Leader", |s, p| run_edit(s, &with_mode(p, "remove")))
|
||||
.menu(&["Modify", "Object", "Multileader", "Remove Leader"])
|
||||
.params("{handle, points?: [[x,y]...] (pick the leaders), index?: number|[number]}")
|
||||
.interactive(|_| Ok(Box::new(EditM::new(Some(true))))),
|
||||
CommandSpec::new("mleaderalign", "Align Multileaders", run_align)
|
||||
.menu(&["Modify", "Object", "Multileader", "Align"])
|
||||
.alias(&["mleadera"])
|
||||
.params("{handles?, option?: current|distribute|parallel|spacing (default current), spacing?: number, to?: hex (multileader to align to), direction?: [[x,y],[x,y]] | angle?: degrees, points?: [[x,y],[x,y]] (distribute: first and last position)}")
|
||||
.interactive(|_| Ok(Box::new(AlignM::default()))),
|
||||
CommandSpec::new("mleadercollect", "Collect Multileaders", run_collect)
|
||||
.menu(&["Modify", "Object", "Multileader", "Collect"])
|
||||
.alias(&["mleaderc"])
|
||||
.params("{handles?, at: [x,y] (top left of the collected blocks), mode?: horizontal|vertical|wrap (default horizontal), width?: number (wrap), spacing?: number}")
|
||||
.interactive(|_| Ok(Box::new(CollectM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
fn with_mode(p: &Value, mode: &str) -> Value {
|
||||
let mut o = p.as_object().cloned().unwrap_or_default();
|
||||
o.insert("mode".into(), json!(mode));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
fn handle_of(p: &Value, key: &str) -> Option<Handle> {
|
||||
p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
}
|
||||
|
||||
fn get_mleader(s: &Session, h: Handle) -> Option<MLeader> {
|
||||
match s.doc().ok()?.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::MLeader(m)) => Some(m.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn locked(s: &Session, h: Handle) -> bool {
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
|
||||
}
|
||||
|
||||
/// Keyword abbreviation test: "p" / "par" / "parallel" all match "parallel".
|
||||
fn kw_is(input: &str, full: &str) -> bool {
|
||||
let t = input.trim().to_ascii_lowercase();
|
||||
!t.is_empty() && full.to_ascii_lowercase().starts_with(&t)
|
||||
}
|
||||
|
||||
fn seg_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.dot(ab);
|
||||
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p - (a + ab * t)).len()
|
||||
}
|
||||
|
||||
/// The leader of `m` nearest `p`.
|
||||
fn nearest_leader(m: &MLeader, p: Vec2) -> Option<usize> {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, l) in m.leaders.iter().enumerate() {
|
||||
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())).collect();
|
||||
let d = pts.windows(2).filter_map(|w| Some(seg_dist(p, *w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
|
||||
if d.is_finite() && best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
best.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MLEADEREDIT
|
||||
|
||||
fn add_leaders(s: &mut Session, h: Handle, pts: &[Vec2]) -> Result<()> {
|
||||
get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
|
||||
if locked(s, h) {
|
||||
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
|
||||
}
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::MLeader(m) = &mut e.kind {
|
||||
m.leaders.extend(pts.iter().map(|p| vec![v3(*p)]));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_leaders(s: &mut Session, h: Handle, idx: &[usize]) -> Result<()> {
|
||||
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
|
||||
if locked(s, h) {
|
||||
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
|
||||
}
|
||||
if let Some(i) = idx.iter().find(|i| **i >= m.leaders.len()) {
|
||||
return Err(bad("mleaderedit", format!("no leader {i} (the multileader has {})", m.leaders.len())));
|
||||
}
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::MLeader(m) = &mut e.kind {
|
||||
let mut sorted: Vec<usize> = idx.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
for i in sorted.into_iter().rev() {
|
||||
if i < m.leaders.len() {
|
||||
m.leaders.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = match handle_of(p, "handle") {
|
||||
Some(h) => h,
|
||||
None => targets(s, p)?.into_iter().next().ok_or_else(|| bad("mleaderedit", "`handle` (a multileader) is required"))?,
|
||||
};
|
||||
let pts = match p.get("points") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(_) => points_param(p, "points").ok_or_else(|| bad("mleaderedit", "`points` must be [[x,y]...]"))?,
|
||||
};
|
||||
if pts.len() > MAX_ITEMS {
|
||||
return Err(bad("mleaderedit", "too many points"));
|
||||
}
|
||||
match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("add") => {
|
||||
if pts.is_empty() {
|
||||
return Err(bad("mleaderedit", "`points` (arrowhead locations) is required"));
|
||||
}
|
||||
add_leaders(s, h, &pts)?;
|
||||
Ok(json!({ "added": pts.len() }))
|
||||
}
|
||||
Some("remove") => {
|
||||
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
|
||||
let mut idx: Vec<usize> = Vec::new();
|
||||
match p.get("index") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) => {
|
||||
for v in a {
|
||||
idx.push(
|
||||
v.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| bad("mleaderedit", "`index` must be non-negative whole numbers"))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(v) => idx.push(
|
||||
v.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| bad("mleaderedit", "`index` must be a non-negative whole number"))?,
|
||||
),
|
||||
}
|
||||
idx.extend(pts.iter().filter_map(|q| nearest_leader(&m, *q)));
|
||||
if idx.is_empty() {
|
||||
return Err(bad("mleaderedit", "`points` or `index` (the leaders to remove) is required"));
|
||||
}
|
||||
let n = {
|
||||
let mut u = idx.clone();
|
||||
u.sort_unstable();
|
||||
u.dedup();
|
||||
u.len()
|
||||
};
|
||||
remove_leaders(s, h, &idx)?;
|
||||
Ok(json!({ "removed": n }))
|
||||
}
|
||||
Some(_) => Err(bad("mleaderedit", "`mode` must be \"add\" or \"remove\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// MLEADEREDIT at the command line: select a multileader, then arrowhead locations to add (or
|
||||
/// leaders to remove by picking them), Enter to finish.
|
||||
struct EditM {
|
||||
remove: bool,
|
||||
fixed: bool,
|
||||
target: Option<Handle>,
|
||||
sel: SelectPhase,
|
||||
changed: usize,
|
||||
}
|
||||
|
||||
impl EditM {
|
||||
fn new(mode: Option<bool>) -> Self {
|
||||
EditM { remove: mode.unwrap_or(false), fixed: mode.is_some(), target: None, sel: SelectPhase::single(), changed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for EditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADEREDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.target, self.remove) {
|
||||
(None, _) => Prompt::new("Select a multileader", Accept::SELECT),
|
||||
(Some(_), false) if self.fixed => Prompt::new("Specify leader arrowhead location", Accept::POINT),
|
||||
(Some(_), false) => Prompt::new("Specify leader arrowhead location or [Remove leaders]", Accept::POINT).kw(&["Remove leaders"]),
|
||||
(Some(_), true) if self.fixed => Prompt::new("Specify leaders to remove", Accept::POINT),
|
||||
(Some(_), true) => Prompt::new("Specify leaders to remove or [Add leaders]", Accept::POINT).kw(&["Add leaders"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(h) = self.target else {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
|
||||
Some(h) => {
|
||||
self.target = Some(h);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Select a multileader.");
|
||||
self.sel = SelectPhase::single();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
match i {
|
||||
Input::Enter => {
|
||||
s.echo(format!("{} leader(s) {}.", self.changed, if self.remove { "removed" } else { "added" }));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Keyword(k) | Input::Text(k) if !self.fixed && (kw_is(&k, "Remove leaders") || kw_is(&k, "Add leaders")) => {
|
||||
self.remove = kw_is(&k, "Remove leaders");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(pt) => {
|
||||
if self.remove {
|
||||
let Some(m) = get_mleader(s, h) else { return Ok(Step::Cancel) };
|
||||
match nearest_leader(&m, pt) {
|
||||
Some(ix) => {
|
||||
remove_leaders(s, h, &[ix])?;
|
||||
self.changed += 1;
|
||||
}
|
||||
None => s.echo("No leader to remove."),
|
||||
}
|
||||
} else {
|
||||
add_leaders(s, h, &[pt])?;
|
||||
self.changed += 1;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared geometry
|
||||
|
||||
/// Move a multileader's content (landing, text, block) by `d`; the arrowheads stay.
|
||||
fn shift_content(m: &mut MLeader, d: Vec2) {
|
||||
let mv = |v: &mut Vec3| *v = Vec3::new(v.x + d.x, v.y + d.y, v.z);
|
||||
mv(&mut m.landing);
|
||||
if let Some(t) = &mut m.text {
|
||||
mv(&mut t.insert);
|
||||
}
|
||||
if let Some(b) = &mut m.block {
|
||||
mv(&mut b.insert);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_content(s: &mut Session, moves: &[(Handle, Vec2)]) -> Result<()> {
|
||||
let d = s.doc_mut()?;
|
||||
for (h, delta) in moves {
|
||||
if !delta.is_finite() {
|
||||
continue;
|
||||
}
|
||||
d.modify_entity(*h, |e| {
|
||||
if let EntityKind::MLeader(m) = &mut e.kind {
|
||||
shift_content(m, *delta);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MLEADERALIGN
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Spacing {
|
||||
Current,
|
||||
Distribute,
|
||||
Parallel,
|
||||
Value(f64),
|
||||
}
|
||||
|
||||
/// The direction of the last leader segment (arrowhead side to landing).
|
||||
fn segment_dir(m: &MLeader) -> Option<Vec2> {
|
||||
let last = m.leaders.first()?.last()?;
|
||||
let v = m.landing.xy() - last.xy();
|
||||
(v.len() > 1e-12).then(|| v.normalized())
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
|
||||
let mut items: Vec<(Handle, MLeader)> = Vec::new();
|
||||
for h in hs {
|
||||
if Some(*h) == to || items.iter().any(|(x, _)| x == h) || locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = get_mleader(s, *h) {
|
||||
items.push((*h, m));
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad("mleaderalign", "no multileaders to align"));
|
||||
}
|
||||
if items.len() > MAX_ITEMS {
|
||||
return Err(bad("mleaderalign", "too many multileaders"));
|
||||
}
|
||||
let reference = match (spacing, to) {
|
||||
(Spacing::Distribute, _) => None,
|
||||
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
|
||||
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
|
||||
};
|
||||
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
|
||||
match spacing {
|
||||
Spacing::Parallel => {
|
||||
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
|
||||
for (h, m) in &items {
|
||||
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
|
||||
let len = (m.landing.xy() - arrow.xy()).len();
|
||||
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Distribute => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the two points coincide"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
|
||||
let n = items.len();
|
||||
for (i, (h, m)) in items.iter().enumerate() {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
moves.push((*h, a + d * f - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Current | Spacing::Value(_) => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the direction is zero"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
|
||||
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
|
||||
if let Spacing::Value(sp) = spacing {
|
||||
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
|
||||
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
|
||||
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
|
||||
for (k, (h, m)) in pos.iter().enumerate() {
|
||||
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
for (k, (h, m)) in neg.iter().enumerate() {
|
||||
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
} else {
|
||||
for (h, m) in &items {
|
||||
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
move_content(s, &moves)?;
|
||||
Ok(moves.len())
|
||||
}
|
||||
|
||||
fn parse_line(p: &Value, cmd: &str) -> Result<Option<(Vec2, Vec2)>> {
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
if !a.is_finite() {
|
||||
return Err(bad(cmd, "`angle` must be a finite number"));
|
||||
}
|
||||
return Ok(Some((Vec2::new(0.0, 0.0), Vec2::from_angle(a.to_radians()))));
|
||||
}
|
||||
for key in ["direction", "points"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null()) {
|
||||
let pts = points_param(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be [[x,y],[x,y]]")))?;
|
||||
if let (Some(a), Some(b)) = (pts.first(), pts.get(1)) {
|
||||
return Ok(Some((*a, *b)));
|
||||
}
|
||||
return Err(bad(cmd, format!("`{key}` needs two points")));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn run_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let to = match p.get("to") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(handle_of(p, "to").ok_or_else(|| bad("mleaderalign", "`to` must be an object handle"))?),
|
||||
};
|
||||
let spacing = match (str_param(p, "option").map(str::to_ascii_lowercase).as_deref(), p.get("spacing")) {
|
||||
(_, Some(v)) if !v.is_null() => match v.as_f64().filter(|x| x.is_finite() && *x >= 0.0) {
|
||||
Some(x) => Spacing::Value(x),
|
||||
None => return Err(bad("mleaderalign", "`spacing` must be a non-negative number")),
|
||||
},
|
||||
(None | Some("current"), _) => Spacing::Current,
|
||||
(Some("distribute"), _) => Spacing::Distribute,
|
||||
(Some("parallel"), _) => Spacing::Parallel,
|
||||
(Some("spacing"), _) => return Err(bad("mleaderalign", "`spacing` (a number) is required")),
|
||||
(Some(_), _) => return Err(bad("mleaderalign", "`option` must be current, distribute, parallel or spacing")),
|
||||
};
|
||||
let line = parse_line(p, "mleaderalign")?;
|
||||
let n = align(s, &hs, to, spacing, line)?;
|
||||
Ok(json!({ "aligned": n }))
|
||||
}
|
||||
|
||||
/// MLEADERALIGN at the command line: select, Options (Distribute / make segments Parallel /
|
||||
/// Specify spacing / Use current spacing), the multileader to align to, then the direction.
|
||||
#[derive(Default)]
|
||||
struct AlignM {
|
||||
sel: SelectPhase,
|
||||
picked: Vec<Handle>,
|
||||
selecting: bool,
|
||||
spacing: Option<Spacing>,
|
||||
asking_spacing: bool,
|
||||
to: Option<Handle>,
|
||||
first: Option<Vec2>,
|
||||
pick_to: SelectPhase,
|
||||
}
|
||||
|
||||
impl AlignM {
|
||||
fn mode(&self) -> Spacing {
|
||||
self.spacing.unwrap_or(Spacing::Current)
|
||||
}
|
||||
fn finish(&self, s: &mut Session, line: Option<(Vec2, Vec2)>) -> Result<Step> {
|
||||
let r = align(s, &self.picked, self.to, self.mode(), line)?;
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("{r} multileader(s) aligned."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
fn option(&mut self, s: &mut Session, k: &str) -> bool {
|
||||
if kw_is(k, "Distribute") {
|
||||
self.spacing = Some(Spacing::Distribute);
|
||||
} else if kw_is(k, "make segments Parallel") || kw_is(k, "Parallel") {
|
||||
self.spacing = Some(Spacing::Parallel);
|
||||
} else if kw_is(k, "Specify spacing") {
|
||||
self.asking_spacing = true;
|
||||
} else if kw_is(k, "Use current spacing") {
|
||||
self.spacing = Some(Spacing::Current);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
let _ = s;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AlignM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADERALIGN"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
self.pick_to = SelectPhase::single();
|
||||
if self.sel.done {
|
||||
self.picked = self.sel.picked.clone();
|
||||
self.selecting = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
const OPTS: &[&str] = &["Options"];
|
||||
if !self.selecting {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
if self.asking_spacing {
|
||||
return Prompt::new("Specify spacing", Accept::NUMBER);
|
||||
}
|
||||
match (self.mode(), self.to, self.first) {
|
||||
(Spacing::Distribute, _, None) => Prompt::new("Specify first point or [Options]", Accept::POINT).kw(OPTS),
|
||||
(Spacing::Distribute, _, Some(a)) => {
|
||||
let mut p = Prompt::new("Specify second point", Accept::POINT);
|
||||
p.base = Some(a);
|
||||
p
|
||||
}
|
||||
(_, None, _) => Prompt::new("Select multileader to align to or [Options]", Accept::SELECT).kw(OPTS),
|
||||
(Spacing::Parallel, Some(_), _) => Prompt::new("Specify direction", Accept::POINT),
|
||||
(_, Some(_), None) => Prompt::new("Specify direction (first point)", Accept::POINT),
|
||||
(_, Some(_), Some(a)) => {
|
||||
let mut p = Prompt::new("Specify direction (second point)", Accept::POINT);
|
||||
p.base = Some(a);
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.selecting {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.picked = hs;
|
||||
self.selecting = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.asking_spacing {
|
||||
return match &i {
|
||||
Input::Text(t) => match super::machines::number(t).filter(|v| v.is_finite() && *v >= 0.0) {
|
||||
Some(v) => {
|
||||
self.spacing = Some(Spacing::Value(v));
|
||||
self.asking_spacing = false;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Requires a non-negative number.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
if kw_is(k, "Options") {
|
||||
s.echo("Enter an option [Distribute/make segments Parallel/Specify spacing/Use current spacing]: type D, P, S or U.");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let k = k.clone();
|
||||
if self.option(s, &k) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
}
|
||||
let distribute = self.mode() == Spacing::Distribute;
|
||||
if !distribute && self.to.is_none() {
|
||||
if let SelOutcome::Done(hs) = self.pick_to.feed(s, &i)? {
|
||||
s.set_selection(Vec::new());
|
||||
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
|
||||
Some(h) => self.to = Some(h),
|
||||
None => {
|
||||
s.echo("Select a multileader.");
|
||||
self.pick_to = SelectPhase::single();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let Input::Point(pt) = i else { return Ok(Step::Continue) };
|
||||
if self.mode() == Spacing::Parallel {
|
||||
return self.finish(s, None);
|
||||
}
|
||||
match self.first.take() {
|
||||
None => {
|
||||
self.first = Some(pt);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(a) => self.finish(s, Some((a, pt))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MLEADERCOLLECT
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Layout {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Wrap(f64),
|
||||
}
|
||||
|
||||
/// Size and centre of a block reference's extents (the insertion point when it has none).
|
||||
fn block_box(s: &Session, ins: &Insert) -> Result<(Vec2, Vec2)> {
|
||||
let d = s.doc()?;
|
||||
let probe = Entity { handle: Handle(0), common: Default::default(), kind: EntityKind::Insert(ins.clone()) };
|
||||
let b = cadcraft_doc::entity_bounds(d, &probe, 0);
|
||||
if b.is_empty() || !b.center().is_finite() {
|
||||
return Ok((Vec2::new(0.0, 0.0), ins.insert.xy()));
|
||||
}
|
||||
Ok((b.max - b.min, b.center()))
|
||||
}
|
||||
|
||||
fn collect(s: &mut Session, hs: &[Handle], at: Vec2, layout: Layout, spacing: Option<f64>) -> Result<Value> {
|
||||
let mut items: Vec<(Handle, MLeader)> = Vec::new();
|
||||
for h in hs {
|
||||
if items.iter().any(|(x, _)| x == h) || locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = get_mleader(s, *h).filter(|m| m.block.is_some()) {
|
||||
items.push((*h, m));
|
||||
}
|
||||
}
|
||||
if items.len() > MAX_ITEMS {
|
||||
return Err(bad("mleadercollect", "too many multileaders"));
|
||||
}
|
||||
if items.len() < 2 {
|
||||
return Err(bad("mleadercollect", "need two or more multileaders with block content"));
|
||||
}
|
||||
let gap = spacing.unwrap_or_else(|| {
|
||||
let d = s.doc().ok();
|
||||
d.and_then(|d| d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&items[0].1.style)).map(|m| m.landing_gap)).unwrap_or(0.09)
|
||||
});
|
||||
// Lay the blocks out from the top left corner at `at`.
|
||||
let (mut x, mut y, mut row_h) = (0.0_f64, 0.0_f64, 0.0_f64);
|
||||
let mut placed: Vec<Insert> = Vec::new();
|
||||
for (_, m) in &items {
|
||||
let Some(b) = &m.block else { continue };
|
||||
let (size, centre) = block_box(s, b)?;
|
||||
match layout {
|
||||
Layout::Horizontal => {}
|
||||
Layout::Vertical => {
|
||||
if x > 0.0 || row_h > 0.0 {
|
||||
y -= row_h + gap;
|
||||
x = 0.0;
|
||||
row_h = 0.0;
|
||||
}
|
||||
}
|
||||
Layout::Wrap(w) => {
|
||||
if x > 0.0 && x + size.x > w {
|
||||
y -= row_h + gap;
|
||||
x = 0.0;
|
||||
row_h = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = Vec2::new(at.x + x + size.x / 2.0, at.y + y - size.y / 2.0);
|
||||
let mut ins = b.clone();
|
||||
ins.insert = Vec3::new(b.insert.x + target.x - centre.x, b.insert.y + target.y - centre.y, b.insert.z);
|
||||
placed.push(ins);
|
||||
x += size.x + gap;
|
||||
row_h = row_h.max(size.y);
|
||||
}
|
||||
let (first_h, first) = items[0].clone();
|
||||
let mut merged = first;
|
||||
merged.leaders = items.iter().flat_map(|(_, m)| m.leaders.iter().cloned()).collect();
|
||||
merged.landing = Vec3::new(at.x, at.y, merged.landing.z);
|
||||
merged.dogleg = 0.0;
|
||||
merged.text = None;
|
||||
let mut rest = placed.split_off(1.min(placed.len()));
|
||||
merged.block = placed.pop();
|
||||
let common = s.doc()?.entity(first_h).map(|e| e.common.clone());
|
||||
{
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(first_h, |e| e.kind = EntityKind::MLeader(merged))?;
|
||||
for (h, _) in items.iter().skip(1) {
|
||||
d.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
// The other blocks stay block references next to the first (the model has one block per multileader).
|
||||
let mut extra = Vec::new();
|
||||
for ins in rest.drain(..) {
|
||||
let h = s.add_entity(EntityKind::Insert(ins))?;
|
||||
if let Some(c) = &common {
|
||||
s.doc_mut()?.modify_entity(h, |e| e.common = c.clone())?;
|
||||
}
|
||||
extra.push(h.hex());
|
||||
}
|
||||
Ok(json!({ "handle": first_h.hex(), "blocks": extra, "collected": items.len() }))
|
||||
}
|
||||
|
||||
fn run_collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let at = point_req("mleadercollect", p, "at")?;
|
||||
let spacing = match p.get("spacing") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
Some(v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).ok_or_else(|| bad("mleadercollect", "`spacing` must be a non-negative number"))?)
|
||||
}
|
||||
};
|
||||
let layout = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("horizontal") => Layout::Horizontal,
|
||||
Some("vertical") => Layout::Vertical,
|
||||
Some("wrap") => {
|
||||
let w = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite() && *w > 0.0);
|
||||
Layout::Wrap(w.ok_or_else(|| bad("mleadercollect", "`width` (a positive number) is required for wrap"))?)
|
||||
}
|
||||
Some(_) => return Err(bad("mleadercollect", "`mode` must be horizontal, vertical or wrap")),
|
||||
};
|
||||
collect(s, &hs, at, layout, spacing)
|
||||
}
|
||||
|
||||
/// MLEADERCOLLECT at the command line: select multileaders, then the location (Vertical /
|
||||
/// Horizontal / Wrap to change the arrangement; Wrap asks for the width).
|
||||
struct CollectM {
|
||||
sel: SelectPhase,
|
||||
picked: Vec<Handle>,
|
||||
selecting: bool,
|
||||
layout: Layout,
|
||||
asking_width: bool,
|
||||
}
|
||||
|
||||
impl Default for CollectM {
|
||||
fn default() -> Self {
|
||||
CollectM { sel: SelectPhase::default(), picked: Vec::new(), selecting: false, layout: Layout::Horizontal, asking_width: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for CollectM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADERCOLLECT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.picked = self.sel.picked.clone();
|
||||
self.selecting = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.selecting {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
if self.asking_width {
|
||||
return Prompt::new("Specify width", Accept::NUMBER);
|
||||
}
|
||||
Prompt::new("Specify collected multileader location or [Vertical/Horizontal/Wrap]", Accept::POINT)
|
||||
.kw(&["Vertical", "Horizontal", "Wrap"])
|
||||
.default("Horizontal")
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.selecting {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.picked = hs;
|
||||
self.selecting = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.asking_width {
|
||||
if let Input::Text(t) = &i {
|
||||
match super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
Some(w) => {
|
||||
self.layout = Layout::Wrap(w);
|
||||
self.asking_width = false;
|
||||
}
|
||||
None => s.echo("Requires a positive number."),
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) | Input::Text(k) => {
|
||||
if kw_is(&k, "Vertical") {
|
||||
self.layout = Layout::Vertical;
|
||||
} else if kw_is(&k, "Horizontal") {
|
||||
self.layout = Layout::Horizontal;
|
||||
} else if kw_is(&k, "Wrap") {
|
||||
self.asking_width = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(at) => {
|
||||
let r = collect(s, &self.picked, at, self.layout, None)?;
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("{} multileader(s) collected.", r.get("collected").and_then(Value::as_u64).unwrap_or(0)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mleader_edit_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,122 @@
|
||||
use cadcraft_doc::{EntityKind, Handle, MLeader};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Input, Session};
|
||||
|
||||
fn hd(v: &Value) -> Handle {
|
||||
Handle::parse_hex(v["handle"].as_str().unwrap_or("0")).unwrap_or(Handle(0))
|
||||
}
|
||||
|
||||
fn ml(s: &Session, h: Handle) -> MLeader {
|
||||
match s.doc().unwrap().entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::MLeader(m)) => m,
|
||||
other => panic!("not a multileader: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_ml(s: &mut Session, arrow: [f64; 2], land: [f64; 2]) -> Handle {
|
||||
hd(&s.execute("mleader", &json!({ "points": [arrow, land], "text": "N" })).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_adds_and_removes_leaders() {
|
||||
let mut s = Session::new();
|
||||
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
|
||||
s.execute("mleaderedit", &json!({ "handle": h.hex(), "points": [[1, 9], [8, 0]] })).unwrap();
|
||||
assert_eq!(ml(&s, h).leaders.len(), 3);
|
||||
// Pick the leader near (8, 0): it is leader 2.
|
||||
s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "points": [[7.5, 0.5]] })).unwrap();
|
||||
let m = ml(&s, h);
|
||||
assert_eq!(m.leaders.len(), 2);
|
||||
assert!(m.leaders.iter().all(|l| l[0].x != 8.0));
|
||||
assert!(s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "index": 7 })).is_err());
|
||||
// Interactive: Add Leader then Remove Leader, one undo step each.
|
||||
s.start("mleaderedit.add").unwrap();
|
||||
s.input(Input::Pick(vec![h])).unwrap();
|
||||
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(ml(&s, h).leaders.len(), 3);
|
||||
s.start("mleaderedit.remove").unwrap();
|
||||
s.input(Input::Pick(vec![h])).unwrap();
|
||||
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(ml(&s, h).leaders.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_distribute_parallel_and_spacing() {
|
||||
let mut s = Session::new();
|
||||
let a = text_ml(&mut s, [0.0, 0.0], [0.0, 4.0]);
|
||||
let b = text_ml(&mut s, [3.0, 0.0], [3.0, 7.0]);
|
||||
let c = text_ml(&mut s, [9.0, 0.0], [9.0, 2.0]);
|
||||
// Current spacing: all landings on the horizontal line through a's landing.
|
||||
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]] })).unwrap();
|
||||
assert!((ml(&s, b).landing.y - 4.0).abs() < 1e-9 && (ml(&s, c).landing.y - 4.0).abs() < 1e-9);
|
||||
assert!((ml(&s, b).text.unwrap().insert.y - 4.0).abs() < 1e-6, "text moves with the landing");
|
||||
assert_eq!(ml(&s, b).leaders[0][0].y, 0.0, "arrowheads stay");
|
||||
// Specified spacing along +x.
|
||||
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]], "spacing": 2 })).unwrap();
|
||||
assert!((ml(&s, b).landing.x - 2.0).abs() < 1e-9 && (ml(&s, c).landing.x - 4.0).abs() < 1e-9);
|
||||
// Distribute between two points.
|
||||
s.execute("mleaderalign", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "option": "distribute", "points": [[0, 10], [10, 10]] })).unwrap();
|
||||
let xs: Vec<f64> = [a, b, c].iter().map(|h| ml(&s, *h).landing.x).collect();
|
||||
assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.0).abs() < 1e-9));
|
||||
// Parallel: segments take the reference's direction.
|
||||
s.execute("mleaderalign", &json!({ "handles": [b.hex()], "to": a.hex(), "option": "parallel" })).unwrap();
|
||||
let (ra, rb) = (ml(&s, a), ml(&s, b));
|
||||
let da = (ra.landing.xy() - ra.leaders[0][0].xy()).normalized();
|
||||
let db = (rb.landing.xy() - rb.leaders[0][0].xy()).normalized();
|
||||
assert!((da.x * db.y - da.y * db.x).abs() < 1e-9);
|
||||
// Interactive.
|
||||
s.set_selection(Vec::new());
|
||||
s.start("mleaderalign").unwrap();
|
||||
s.input(Input::Pick(vec![b, c])).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Pick(vec![a])).unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.0, 1.0))).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!((ml(&s, b).landing.x - ml(&s, a).landing.x).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_blocks_into_one_multileader() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap();
|
||||
let l = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
|
||||
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l.hex()], "keep": "delete" })).unwrap();
|
||||
let mk = |s: &mut Session, x: f64| hd(&s.execute("mleader", &json!({ "points": [[x, 0], [x, 5]], "block": "B" })).unwrap());
|
||||
let (a, b, c) = (mk(&mut s, 10.0), mk(&mut s, 20.0), mk(&mut s, 30.0));
|
||||
let r =
|
||||
s.execute("mleadercollect", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "at": [0, 20], "mode": "horizontal", "spacing": 1 })).unwrap();
|
||||
assert_eq!(r["collected"], 3);
|
||||
let m = ml(&s, a);
|
||||
assert_eq!(m.leaders.len(), 3, "all arrowheads on the collected multileader");
|
||||
assert!(s.doc().unwrap().entity(b).is_none() && s.doc().unwrap().entity(c).is_none());
|
||||
assert_eq!(r["blocks"].as_array().map(Vec::len), Some(2));
|
||||
// Wrap needs a width.
|
||||
assert!(s.execute("mleadercollect", &json!({ "handles": [a.hex()], "at": [0, 0], "mode": "wrap" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_mleader_edit_params_never_panic() {
|
||||
let mut s = Session::new();
|
||||
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
|
||||
for (id, p) in [
|
||||
("mleaderedit", json!({ "handle": "zz", "points": [[1, 1]] })),
|
||||
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": -1 })),
|
||||
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": 1e300 })),
|
||||
("mleaderedit", json!({ "handle": h.hex(), "points": [[1e308, f64::MAX], "x"] })),
|
||||
("mleaderalign", json!({ "handles": [h.hex()], "to": h.hex(), "direction": [[0, 0], [0, 0]] })),
|
||||
("mleaderalign", json!({ "handles": [], "option": "distribute", "points": [[0, 0], [1, 1]] })),
|
||||
("mleaderalign", json!({ "handles": [h.hex()], "spacing": -1, "to": "7", "angle": 1e308 })),
|
||||
("mleaderalign", json!({ "handles": [h.hex(), "q"], "option": "nonsense" })),
|
||||
("mleadercollect", json!({ "handles": [h.hex()], "at": [0, 0] })),
|
||||
("mleadercollect", json!({ "handles": [h.hex(), h.hex()], "at": [1e308, 1e308], "mode": "wrap", "width": 0 })),
|
||||
] {
|
||||
let _ = s.execute(id, &p);
|
||||
}
|
||||
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
|
||||
}
|
||||
@@ -5,7 +5,7 @@ pub(crate) mod annoscale;
|
||||
mod annotate;
|
||||
mod array;
|
||||
mod audit;
|
||||
mod blocks;
|
||||
pub(crate) mod blocks;
|
||||
pub mod clipboard;
|
||||
pub mod constraints;
|
||||
mod draw;
|
||||
@@ -21,7 +21,9 @@ pub(crate) mod hatch;
|
||||
mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
@@ -29,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;
|
||||
@@ -37,6 +40,8 @@ mod spline_opts;
|
||||
mod table;
|
||||
mod tolerance;
|
||||
mod trimextend;
|
||||
mod txt2mtxt;
|
||||
pub use txt2mtxt::Txt2MtxtOptions;
|
||||
mod utility;
|
||||
mod view;
|
||||
|
||||
@@ -176,6 +181,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(draw::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(annotate::specs());
|
||||
v.extend(mleader_edit::specs());
|
||||
v.extend(annoscale::specs());
|
||||
v.extend(hatch::specs());
|
||||
v.extend(overkill::specs());
|
||||
@@ -197,7 +203,9 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
+354
-84
@@ -11,6 +11,7 @@ use super::helpers::*;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::trimextend::{self, TrimM};
|
||||
use super::*;
|
||||
use crate::select::{SelectKind, SelectMode};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -48,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("stretch", "Stretch", run_stretch)
|
||||
.menu(&["Modify", "Stretch"])
|
||||
.alias(&["s"])
|
||||
.params("{window: [[x,y],[x,y]], delta: [dx,dy]}")
|
||||
.params("{window: [[x,y],[x,y]] | polygon: [[x,y],...] | fence: [[x,y],...], delta: [dx,dy]}")
|
||||
.interactive(|_| Ok(Box::new(StretchM::default()))),
|
||||
CommandSpec::new("offset", "Offset", run_offset)
|
||||
.menu(&["Modify", "Offset"])
|
||||
@@ -91,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)))),
|
||||
]
|
||||
}
|
||||
@@ -201,9 +202,26 @@ fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Move the vertices of `e` that lie inside any of the windows `wins` by `d` (STRETCH).
|
||||
fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) {
|
||||
let inside = |p: Vec2| wins.iter().any(|bx| bx.contains(p));
|
||||
/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first
|
||||
/// point.
|
||||
#[derive(Clone)]
|
||||
enum StretchArea {
|
||||
Box(cadcraft_geom::Bounds2),
|
||||
Polygon(Vec<Vec2>),
|
||||
}
|
||||
|
||||
impl StretchArea {
|
||||
fn contains(&self, p: Vec2) -> bool {
|
||||
match self {
|
||||
StretchArea::Box(bx) => bx.contains(p),
|
||||
StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
|
||||
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
|
||||
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
|
||||
let mv = |p: &mut cadcraft_geom::Vec3| {
|
||||
if inside(p.xy()) {
|
||||
p.x += d.x;
|
||||
@@ -246,16 +264,34 @@ fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) {
|
||||
}
|
||||
|
||||
fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]] is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
let d = delta_of("stretch", p)?;
|
||||
let space = s.space();
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, true);
|
||||
let listed = |key: &str, least: usize| -> Result<Option<Vec<Vec2>>> {
|
||||
let Some(pts) = points_param(p, key) else { return Ok(None) };
|
||||
if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) {
|
||||
return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS)));
|
||||
}
|
||||
Ok(Some(pts))
|
||||
};
|
||||
// A fence marks no area: the objects it crosses move whole.
|
||||
if let Some(fence) = listed("fence", 2)? {
|
||||
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
|
||||
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
|
||||
return Ok(json!({ "stretched": moved.len() }));
|
||||
}
|
||||
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
|
||||
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
|
||||
} else {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
for h in &hs {
|
||||
doc.modify_entity(*h, |e| stretch_entity(e, &[bx], d))?;
|
||||
doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?;
|
||||
}
|
||||
Ok(json!({ "stretched": hs.len() }))
|
||||
}
|
||||
@@ -270,20 +306,20 @@ enum StretchPhase {
|
||||
}
|
||||
|
||||
/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after
|
||||
/// `C`) and windows (left to right, or after `W`) select objects and mark the area whose vertices
|
||||
/// move; objects picked one at a time (or by All/Last/Previous) move whole. Then the base point or
|
||||
/// Displacement, and the second point.
|
||||
/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark
|
||||
/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or
|
||||
/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point.
|
||||
#[derive(Default)]
|
||||
struct StretchM {
|
||||
phase: StretchPhase,
|
||||
objs: Vec<Handle>,
|
||||
/// Objects selected without a window: they move whole.
|
||||
whole: Vec<Handle>,
|
||||
/// The selection windows: vertices inside any of them move.
|
||||
windows: Vec<cadcraft_geom::Bounds2>,
|
||||
/// The selection windows and polygons: vertices inside any of them move.
|
||||
windows: Vec<StretchArea>,
|
||||
removing: bool,
|
||||
/// `C` (true) or `W` (false) was typed: the next two points are that kind of window.
|
||||
mode: Option<bool>,
|
||||
/// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.
|
||||
mode: Option<SelectMode>,
|
||||
/// First corner of the window being drawn.
|
||||
corner: Option<Vec2>,
|
||||
base: Option<Vec2>,
|
||||
@@ -313,20 +349,63 @@ impl StretchM {
|
||||
s.set_selection(self.objs.clone());
|
||||
}
|
||||
|
||||
/// Select with a typed mode that has its points. With too few (a fence of one point, a polygon
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
|
||||
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
|
||||
Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b)))
|
||||
}
|
||||
// A fence marks no area: what it crosses moves whole.
|
||||
_ => None,
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
}
|
||||
self.add(s, hs, whole);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(mut m) = self.mode.take() {
|
||||
match &i {
|
||||
Input::Point(p) => {
|
||||
if m.push(*p) {
|
||||
self.finish_mode(s, m)?;
|
||||
} else {
|
||||
self.mode = Some(m);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => {
|
||||
m.pts.pop();
|
||||
self.mode = Some(m);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// Enter ends a fence or closes a polygon.
|
||||
Input::Enter => {
|
||||
self.finish_mode(s, m)?;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// Anything else leaves the mode and is read as usual.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => match self.corner.take() {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let crossing = self.mode.take().unwrap_or(p.x < c.x);
|
||||
let space = s.space();
|
||||
let hs = crate::select::select_window(s.doc()?, &space, bx, crossing);
|
||||
let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
self.windows.push(bx);
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
self.add(s, hs, false);
|
||||
}
|
||||
None if self.mode.is_some() => self.corner = Some(p),
|
||||
None => {
|
||||
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
@@ -341,15 +420,14 @@ impl StretchM {
|
||||
self.corner = None;
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let picked = match t.trim().to_ascii_lowercase().as_str() {
|
||||
"c" | "crossing" => {
|
||||
self.mode = Some(true);
|
||||
None
|
||||
}
|
||||
"w" | "window" => {
|
||||
self.mode = Some(false);
|
||||
None
|
||||
}
|
||||
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);
|
||||
}
|
||||
let picked = match word.as_str() {
|
||||
"r" | "remove" => {
|
||||
self.removing = true;
|
||||
None
|
||||
@@ -361,10 +439,6 @@ impl StretchM {
|
||||
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
|
||||
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
|
||||
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
|
||||
"cp" | "wp" | "f" | "fence" => {
|
||||
s.echo("Polygon and fence selection are not available yet in STRETCH; use a crossing window.");
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
s.echo("*Invalid selection*");
|
||||
None
|
||||
@@ -374,10 +448,7 @@ impl StretchM {
|
||||
self.add(s, hs, true);
|
||||
}
|
||||
}
|
||||
Input::Enter if self.corner.is_some() || self.mode.is_some() => {
|
||||
self.corner = None;
|
||||
self.mode = None;
|
||||
}
|
||||
Input::Enter if self.corner.is_some() => self.corner = None,
|
||||
Input::Enter if self.objs.is_empty() => return Ok(Step::Done),
|
||||
Input::Enter => {
|
||||
s.remember_selection(&self.objs);
|
||||
@@ -413,9 +484,11 @@ impl Interactive for StretchM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
|
||||
return m.prompt();
|
||||
}
|
||||
match (self.phase, self.corner) {
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
|
||||
(StretchPhase::Select, None) if self.mode.is_some() => Prompt::new("Specify first corner", Accept::POINT),
|
||||
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
|
||||
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
|
||||
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
|
||||
@@ -446,11 +519,25 @@ impl Interactive for StretchM {
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.phase, self.corner, self.base) {
|
||||
(StretchPhase::Select, Some(a), _) => {
|
||||
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
|
||||
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
|
||||
let rect = |a: Vec2| {
|
||||
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
|
||||
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
|
||||
};
|
||||
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
|
||||
let Some(first) = m.pts.first().copied() else { return Vec::new() };
|
||||
if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) {
|
||||
return rect(first);
|
||||
}
|
||||
// The fence or polygon so far, on to the cursor; a polygon closes back to its start.
|
||||
let mut out: Vec<EntityKind> = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect();
|
||||
out.extend(m.pts.last().map(|l| line(*l, c)));
|
||||
if m.kind != SelectKind::Fence && m.pts.len() >= 2 {
|
||||
out.push(line(c, first));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match (self.phase, self.corner, self.base) {
|
||||
(StretchPhase::Select, Some(a), _) => rect(a),
|
||||
(StretchPhase::Second, _, Some(base)) => {
|
||||
let Ok(doc) = s.doc() else { return Vec::new() };
|
||||
let m = Mat3::translate(c - base);
|
||||
@@ -499,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();
|
||||
@@ -597,20 +688,40 @@ pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>>
|
||||
return None;
|
||||
}
|
||||
let n = segs.len();
|
||||
let mut verts: Vec<PolyVertex> = Vec::new();
|
||||
// Where each offset segment starts after joining it to the previous one.
|
||||
let mut starts: Vec<Vec2> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let s = segs.get(i)?;
|
||||
let start = if i == 0 && !pl.closed {
|
||||
starts.push(if i == 0 && !pl.closed {
|
||||
s.start()
|
||||
} else {
|
||||
let prev = segs.get((i + n - 1) % n)?;
|
||||
join_point(prev, s).unwrap_or(s.start())
|
||||
});
|
||||
}
|
||||
let mut verts: Vec<PolyVertex> = Vec::new();
|
||||
for i in 0..n {
|
||||
let s = segs.get(i)?;
|
||||
let start = *starts.get(i)?;
|
||||
let end = if i + 1 < n {
|
||||
*starts.get(i + 1)?
|
||||
} else if pl.closed {
|
||||
*starts.first()?
|
||||
} else {
|
||||
s.end()
|
||||
};
|
||||
let bulge = match s {
|
||||
Segment::Line(_) => 0.0,
|
||||
Segment::Arc { arc, ccw } => {
|
||||
let sw = arc.sweep();
|
||||
cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw })
|
||||
// The joined endpoints may have moved along the circle: take the sweep between them.
|
||||
let sweep = if start.near(end, 1e-9) {
|
||||
arc.sweep()
|
||||
} else if *ccw {
|
||||
ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end))
|
||||
} else {
|
||||
ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start))
|
||||
};
|
||||
cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep })
|
||||
}
|
||||
};
|
||||
verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 });
|
||||
@@ -618,7 +729,6 @@ pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>>
|
||||
if !pl.closed {
|
||||
verts.push(PolyVertex::new(segs.last()?.end()));
|
||||
}
|
||||
// Recompute arc bulges where endpoints moved (approximation: keep sweep sign).
|
||||
Some(verts)
|
||||
}
|
||||
|
||||
@@ -910,12 +1020,18 @@ fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> R
|
||||
let mut out = Vec::new();
|
||||
if p.closed {
|
||||
// Keep the complementary run from hi around to lo.
|
||||
// The pick lies before the first cut or after the last: the piece wraps past the start
|
||||
// vertex, so the run to keep is the stretch between the first and the last cut.
|
||||
let (lo, hi) = (
|
||||
if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total },
|
||||
if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total },
|
||||
);
|
||||
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
|
||||
let (a, b) = if hi > total { (hi - total, lo + total) } else { (hi, lo + total) };
|
||||
let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) };
|
||||
// A single cut leaves the whole outline, opened there.
|
||||
if b <= a + 1e-9 {
|
||||
b += total;
|
||||
}
|
||||
out.push(mk(sub_polyline(&doubled, a, b)));
|
||||
} else {
|
||||
if lo.is_finite() {
|
||||
@@ -1287,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 {
|
||||
@@ -1294,10 +1420,16 @@ 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(_));
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(*h);
|
||||
for mut p in parts {
|
||||
if own_pieces {
|
||||
p.common.xdata.clear();
|
||||
}
|
||||
p.handle = d.new_handle();
|
||||
out.push(p.handle);
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
@@ -1315,8 +1447,21 @@ 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) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let pieces: Vec<EntityKind> = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
@@ -1325,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
|
||||
}
|
||||
@@ -1341,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);
|
||||
@@ -1352,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| {
|
||||
@@ -1377,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())),
|
||||
};
|
||||
@@ -1426,6 +1583,9 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
if let Some(h) = super::modify2::join_curves(s, hs)? {
|
||||
return Ok(Some(h));
|
||||
}
|
||||
if hs.iter().any(|h| super::curves::is_locked(s, *h)) {
|
||||
return Err(EngineError::Other("An object is on a locked layer.".into()));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
|
||||
@@ -1502,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()) }))
|
||||
}
|
||||
@@ -1538,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 {
|
||||
@@ -1554,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> {
|
||||
@@ -1569,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),
|
||||
}
|
||||
}
|
||||
@@ -1712,6 +1913,26 @@ impl SelectThen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry.
|
||||
fn keeps_reading_when_mirrored(k: &EntityKind) -> bool {
|
||||
match k {
|
||||
EntityKind::Insert(i) => !i.attribs.is_empty(),
|
||||
k => matches!(
|
||||
k,
|
||||
EntityKind::Text(_)
|
||||
| EntityKind::AttDef(_)
|
||||
| EntityKind::MText(_)
|
||||
| EntityKind::Dimension(_)
|
||||
| EntityKind::MLeader(_)
|
||||
| EntityKind::Table(_)
|
||||
| EntityKind::Tolerance(_)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn.
|
||||
const MAX_MIRROR_TEXT_PREVIEW: usize = 1000;
|
||||
|
||||
/// A finite length greater than zero (rejects 0, negatives, NaN and infinity).
|
||||
fn positive_length(v: Option<f64>) -> Option<f64> {
|
||||
v.filter(|f| f.is_finite() && *f > 1e-12)
|
||||
@@ -1750,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 {
|
||||
@@ -1797,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]);
|
||||
@@ -1927,6 +2156,41 @@ impl Interactive for SelectThen {
|
||||
out.push(line(base, c));
|
||||
out
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if (self.op == Op::Rotate && self.reference) || !self.sel.done {
|
||||
return None;
|
||||
}
|
||||
let base = self.pts.first().copied()?;
|
||||
let matrix = match self.op {
|
||||
Op::Move | Op::Copy => Mat3::translate(c - base),
|
||||
Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)),
|
||||
Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)),
|
||||
Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?),
|
||||
Op::Mirror if !self.ask_erase => Mat3::mirror(base, c),
|
||||
_ => return None,
|
||||
};
|
||||
let mut extra = vec![line(base, c)];
|
||||
if self.op != Op::Mirror {
|
||||
return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false });
|
||||
}
|
||||
// Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't
|
||||
// draw: those objects are transformed one by one, the rest is mirrored on the GPU.
|
||||
let d = s.doc().ok()?;
|
||||
let mut handles = Vec::with_capacity(self.objs.len());
|
||||
let mut texts = 0;
|
||||
for h in &self.objs {
|
||||
let Some(e) = d.entity(*h) else { continue };
|
||||
if !keeps_reading_when_mirrored(&e.kind) {
|
||||
handles.push(*h);
|
||||
} else if texts < MAX_MIRROR_TEXT_PREVIEW {
|
||||
texts += 1;
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&matrix);
|
||||
extra.push(k);
|
||||
}
|
||||
}
|
||||
Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// An OFFSET option prompt opened from "Specify offset distance".
|
||||
@@ -2730,6 +2994,12 @@ impl Interactive for DisplaceM {
|
||||
}
|
||||
self.inner.preview(s, c)
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.displacement {
|
||||
return None;
|
||||
}
|
||||
self.inner.drag(s, c)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn _unused(_: Circle) {}
|
||||
@@ -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}")
|
||||
@@ -454,10 +459,17 @@ impl Interactive for PeditM {
|
||||
let sel = s.selection();
|
||||
if let [h] = sel.as_slice() {
|
||||
let k = curves::entity(s, *h)?.kind;
|
||||
if matches!(k, EntityKind::LwPolyline(_)) {
|
||||
self.h = Some(*h);
|
||||
self.original = self.poly(s).map(|p| p.vertices);
|
||||
self.phase = PPhase::Main;
|
||||
match k {
|
||||
EntityKind::LwPolyline(_) => {
|
||||
self.h = Some(*h);
|
||||
self.original = self.poly(s).map(|p| p.vertices);
|
||||
self.phase = PPhase::Main;
|
||||
}
|
||||
EntityKind::Line(_) | EntityKind::Arc(_) => {
|
||||
self.h = Some(*h);
|
||||
self.phase = PPhase::Convert;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
@@ -754,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);
|
||||
@@ -775,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();
|
||||
@@ -1308,6 +1327,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
EntityKind::AttDef(a) => {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => z(&mut t.insert),
|
||||
EntityKind::Insert(i) => {
|
||||
@@ -1315,6 +1337,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
for a in &mut i.attribs {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
@@ -1445,7 +1470,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
|
||||
fn run_chspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?;
|
||||
Ok(json!({ "moved": n, "to": match target { Space::Model => "model".to_string(), Space::Paper(n) => n } }))
|
||||
Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -228,6 +228,9 @@ fn ends(sp: &Spline) -> (Vec2, Vec2) {
|
||||
/// larger arc or the full ellipse (gaps between them close). Otherwise the objects joined end
|
||||
/// to end with the first one become one spline. The first joined object keeps its handle.
|
||||
pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
// A handle named twice is one object: a repeat would join it with itself or remove the result.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let hs: Vec<Handle> = hs.iter().copied().filter(|h| seen.insert(*h)).collect();
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
if !ents.iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
|
||||
@@ -313,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();
|
||||
@@ -683,3 +716,56 @@ fn editing_commands_leave_locked_layer_objects_alone() {
|
||||
s.execute("explode", &json!({"handles": [r]})).unwrap();
|
||||
assert_ne!(model_dump(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pedit_preselected_line_asks_to_convert() {
|
||||
// Yes converts the pre-selected line and continues with the option prompt.
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.set_selection(vec![Handle::parse_hex(&l).unwrap()]);
|
||||
s.cmdline("pedit").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(_)), "nothing changes before the answer");
|
||||
s.cmdline("y").unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::LwPolyline(_)));
|
||||
s.cmdline("c").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
|
||||
// No ends the command and leaves the line alone.
|
||||
let m = h(&s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap());
|
||||
s.set_selection(vec![Handle::parse_hex(&m).unwrap()]);
|
||||
s.cmdline("pedit").unwrap();
|
||||
s.cmdline("n").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(kind(&s, &m), EntityKind::Line(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_polyline_arc_bulge_follows_joined_endpoints() {
|
||||
let mut s = Session::new();
|
||||
// A diagonal line into a CCW semicircle centred on (10, 5); offsetting inward moves the arc start.
|
||||
let pl = pline(&mut s, json!([[0, -5], {"p": [10, 0], "bulge": 1}, [10, 10]]), false);
|
||||
s.execute("offset", &json!({"handle": pl, "distance": 1, "side": [12, 5]})).unwrap();
|
||||
let p = poly(&last(&s));
|
||||
let (a, b) = (p.vertices[1], p.vertices[2]);
|
||||
let (arc, ccw) = cadcraft_geom::bulge_to_arc(a.p, b.p, a.bulge).unwrap();
|
||||
assert!(ccw);
|
||||
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}");
|
||||
}
|
||||
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user